Files
openclaw/extensions/codex/src/native-thread-tool.test.ts
Peter Steinberger 0d981095d4 refactor(codex): store app-server thread bindings in SQLite plugin state (#101210)
* test(codex): shorten placeholder auth fixture strings

* feat(plugin-state): add reject-new overflow policy for keyed namespaces

* feat(agents): thread agent identity and session generation through reset and hooks

* refactor(codex): add SQLite-backed app-server thread binding store

* refactor(codex): move app-server runtime callers onto the binding store

* refactor(codex): move conversation, entry, and thread-tool flows onto the binding store

* refactor(codex): move command handlers onto the binding store

* feat(codex): import legacy binding sidecars via doctor state migration

* fix(codex): keep doctor sidecar scan inside stateDir

* fix(codex): make binding migration landable

* chore(changelog): defer release note

* test(plugin-state): stabilize durable-capacity ordering
2026-07-07 03:03:44 +01:00

262 lines
8.8 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
import { CODEX_INTERACTIVE_THREAD_SOURCE_KINDS } from "./app-server/protocol.js";
import {
readCodexAppServerBinding,
registerCodexTestSessionIdentity,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
writeCodexAppServerBinding,
} from "./app-server/session-binding.test-helpers.js";
import { createCodexThreadsTool } from "./native-thread-tool.js";
describe("native Codex thread tool", () => {
let root: string;
let sessionFile: string;
async function withFixture(run: () => void | Promise<void>): Promise<void> {
await withTempDir("openclaw-codex-threads-", async (tempRoot) => {
root = tempRoot;
sessionFile = path.join(root, "sessions", "session-id.jsonl");
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
await fs.writeFile(sessionFile, "");
resetCodexTestBindingStore();
registerCodexTestSessionIdentity(
"session-id",
"session-id",
"agent:main:telegram:direct:owner",
);
await run();
});
}
function createTool(params?: {
owner?: boolean;
homeScope?: "agent" | "user";
request?: ReturnType<typeof vi.fn>;
sessionId?: string | null;
}) {
const context: OpenClawPluginToolContext = {
config: {},
agentId: "main",
agentDir: path.join(root, "agent"),
workspaceDir: path.join(root, "workspace"),
sessionKey: "agent:main:telegram:direct:owner",
sessionId: params?.sessionId === null ? undefined : (params?.sessionId ?? "session-id"),
senderIsOwner: params?.owner ?? true,
};
const runtime = createPluginRuntimeMock({
agent: {
session: {
getSessionEntry: () => ({ sessionId: "session-id", sessionFile, updatedAt: Date.now() }),
resolveStorePath: () => path.join(root, "sessions", "sessions.json"),
resolveSessionFilePath: () => sessionFile,
},
},
});
return createCodexThreadsTool({
bindingStore: testCodexAppServerBindingStore,
context,
runtime,
getPluginConfig: () => ({ appServer: { homeScope: params?.homeScope ?? "user" } }),
request: params?.request as never,
});
}
it("materializes only for owner turns in shared user-home mode", () =>
withFixture(() => {
expect(createTool()).not.toBeNull();
expect(createTool({ owner: false })).toBeNull();
expect(createTool({ homeScope: "agent" })).toBeNull();
}));
it("lists native threads with bounded deterministic parameters", () =>
withFixture(async () => {
const response = { data: [{ id: "thread-1", status: { type: "idle" } }] };
const request = vi.fn(async () => response);
const tool = createTool({ request });
const result = await tool?.execute("call-1", {
action: "list",
archived: true,
cursor: "next-page",
limit: 12,
search: "coexistence",
});
expect(request).toHaveBeenCalledWith(
{ appServer: { homeScope: "user" } },
CODEX_CONTROL_METHODS.listThreads,
{
archived: true,
cursor: "next-page",
limit: 12,
modelProviders: [],
searchTerm: "coexistence",
sortKey: "recency_at",
sortDirection: "desc",
sourceKinds: [...CODEX_INTERACTIVE_THREAD_SOURCE_KINDS],
},
expect.objectContaining({
sessionId: "session-id",
sessionKey: "agent:main:telegram:direct:owner",
}),
);
expect(result?.details).toEqual(response);
}));
it("forks a native thread and attaches the fork to the OpenClaw session", () =>
withFixture(async () => {
const request = vi.fn(async () => ({
thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } },
model: "gpt-5.5",
modelProvider: "openai",
}));
const tool = createTool({ request, sessionId: null });
const result = await tool?.execute("call-2", {
action: "fork",
thread_id: "source-thread",
});
expect(request).toHaveBeenCalledWith(
{ appServer: { homeScope: "user" } },
CODEX_CONTROL_METHODS.forkThread,
{ threadId: "source-thread", threadSource: "user" },
expect.any(Object),
);
await expect(
readCodexAppServerBinding("session-id", { agentDir: path.join(root, "agent") }),
).resolves.toMatchObject({
threadId: "forked-thread",
cwd: "/tmp/project",
model: "gpt-5.5",
modelProvider: "openai",
historyCoveredThrough: expect.any(String),
});
expect(result?.details).toMatchObject({
action: "fork",
sourceThreadId: "source-thread",
attached: true,
});
}));
it("reports a conflict when a fork cannot attach to the current generation", () =>
withFixture(async () => {
const request = vi.fn(async () => ({
thread: { id: "forked-thread", cwd: "/tmp/project", status: { type: "idle" } },
}));
const mutate = vi
.spyOn(testCodexAppServerBindingStore, "mutate")
.mockResolvedValueOnce(false);
try {
await expect(
createTool({ request })?.execute("call-conflict", {
action: "fork",
thread_id: "source-thread",
}),
).rejects.toThrow("binding changed before the fork could be attached");
} finally {
mutate.mockRestore();
}
}));
it("refuses to archive the active thread bound to this OpenClaw session", () =>
withFixture(async () => {
await writeCodexAppServerBinding("session-id", {
threadId: "active-thread",
cwd: "/tmp/project",
});
const request = vi.fn(async (_config, method: string) => {
if (method === CODEX_CONTROL_METHODS.readThread) {
return { thread: { id: "active-thread", status: { type: "active" } } };
}
return {};
});
const tool = createTool({ request });
await expect(
tool?.execute("call-3", {
action: "archive",
thread_id: "active-thread",
confirm: true,
}),
).rejects.toThrow("cannot archive the Codex thread active in this OpenClaw session");
expect(request).not.toHaveBeenCalledWith(
expect.anything(),
CODEX_CONTROL_METHODS.archiveThread,
expect.anything(),
expect.anything(),
);
}));
it("archives an idle bound thread and clears its attachment", () =>
withFixture(async () => {
await writeCodexAppServerBinding("session-id", {
threadId: "idle-thread",
cwd: "/tmp/project",
});
const request = vi.fn(async (_config, method: string) => {
if (method === CODEX_CONTROL_METHODS.readThread) {
return { thread: { id: "idle-thread", status: { type: "idle" } } };
}
return {};
});
const tool = createTool({ request });
await tool?.execute("call-4", {
action: "archive",
thread_id: "idle-thread",
confirm: true,
});
expect(request).toHaveBeenCalledWith(
{ appServer: { homeScope: "user" } },
CODEX_CONTROL_METHODS.archiveThread,
{ threadId: "idle-thread" },
expect.any(Object),
);
await expect(readCodexAppServerBinding("session-id")).resolves.toBeUndefined();
}));
it.each([
{
action: "read" as const,
params: { action: "read", thread_id: "thread-1", include_turns: true },
method: CODEX_CONTROL_METHODS.readThread,
requestParams: { threadId: "thread-1", includeTurns: true },
},
{
action: "rename" as const,
params: { action: "rename", thread_id: "thread-1", name: "Shared thread" },
method: CODEX_CONTROL_METHODS.renameThread,
requestParams: { threadId: "thread-1", name: "Shared thread" },
},
{
action: "unarchive" as const,
params: { action: "unarchive", thread_id: "thread-1" },
method: CODEX_CONTROL_METHODS.unarchiveThread,
requestParams: { threadId: "thread-1" },
},
])("routes $action through the typed Codex control method", ({ params, method, requestParams }) =>
withFixture(async () => {
const request = vi.fn(async () => ({ thread: { id: "thread-1" } }));
const tool = createTool({ request });
await tool?.execute("call-5", params);
expect(request).toHaveBeenCalledWith(
{ appServer: { homeScope: "user" } },
method,
requestParams,
expect.any(Object),
);
}),
);
});