diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index dbd0a4d1193..3ed3f8f32e9 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -179,6 +179,44 @@ async function writeExistingBinding( }); } +function createThreadLifecycleAppServerOptions(): Parameters< + typeof startOrResumeThread +>[0]["appServer"] { + return { + start: { + transport: "stdio", + command: "codex", + args: ["app-server"], + headers: {}, + }, + requestTimeoutMs: 60_000, + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: "workspace-write", + }; +} + +function createMessageDynamicTool( + description: string, + actions: string[] = ["send"], +): Parameters[0]["dynamicTools"][number] { + return { + name: "message", + description, + inputSchema: { + type: "object", + properties: { + action: { + type: "string", + enum: actions, + }, + }, + required: ["action"], + additionalProperties: false, + }, + }; +} + describe("runCodexAppServerAttempt", () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-")); @@ -731,6 +769,76 @@ describe("runCodexAppServerAttempt", () => { }); }); + it("resumes a bound Codex thread when only dynamic tool descriptions change", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-existing"); + } + if (method === "thread/resume") { + return { thread: { id: "thread-existing" }, modelProvider: "openai" }; + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [ + createMessageDynamicTool("Send and manage messages for the current Slack thread."), + ], + appServer, + }); + const binding = await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [ + createMessageDynamicTool("Send and manage messages for the current Discord channel."), + ], + appServer, + }); + + expect(binding.threadId).toBe("thread-existing"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); + }); + + it("starts a new Codex thread when dynamic tool schemas change", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + let nextThread = 1; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult(`thread-${nextThread++}`); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.", ["send"])], + appServer, + }); + const binding = await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.", ["send", "read"])], + appServer, + }); + + expect(binding.threadId).toBe("thread-2"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]); + }); + it("passes configured app-server policy, sandbox, service tier, and model on resume", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 61e4ba5742a..493986dd55f 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -163,7 +163,23 @@ export function buildTurnStartParams( } function fingerprintDynamicTools(dynamicTools: JsonValue[]): string { - return JSON.stringify(dynamicTools.map(stabilizeJsonValue)); + return JSON.stringify(dynamicTools.map(fingerprintDynamicToolSpec)); +} + +function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue { + if (!isJsonObject(tool)) { + return stabilizeJsonValue(tool); + } + const stable: JsonObject = {}; + for (const [key, child] of Object.entries(tool).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + if (key === "description") { + continue; + } + stable[key] = stabilizeJsonValue(child); + } + return stable; } function stabilizeJsonValue(value: JsonValue): JsonValue {