diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 70c3d258d35..e230e781886 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -6314,6 +6314,104 @@ describe("runCodexAppServerAttempt", () => { expect(inputText).toContain("make the default webpage openclaw"); }); + it("projects newer mirrored history when resuming an existing Codex thread binding", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" }); + const binding = await readCodexAppServerBinding(sessionFile); + const bindingUpdatedAt = Date.parse(binding?.updatedAt ?? ""); + if (!Number.isFinite(bindingUpdatedAt)) { + throw new Error("expected valid Codex binding timestamp"); + } + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage( + userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000), + ); + sessionManager.appendMessage( + assistantMessage("David Ondrej was mentioned in that prior thread", bindingUpdatedAt + 2_000), + ); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + params.prompt = "is the previous message trustworthy?"; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" }); + await run; + + expect(harness.requests.map((request) => request.method)).toContain("thread/resume"); + const turnStart = harness.requests.find((request) => request.method === "turn/start"); + const inputText = + (turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ?? + ""; + + expect(inputText).toContain("OpenClaw assembled context for this turn:"); + expect(inputText).toContain("we were discussing the Sonnet leak screenshots"); + expect(inputText).toContain("David Ondrej was mentioned in that prior thread"); + expect(inputText).toContain("Current user request:"); + expect(inputText).toContain("is the previous message trustworthy?"); + }); + + it("does not reproject Codex-owned mirrored messages on consecutive resumes", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" }); + const oldBindingUpdatedAt = Date.now() - 60_000; + const bindingPath = `${sessionFile}.codex-app-server.json`; + const bindingPayload = JSON.parse(await fs.readFile(bindingPath, "utf8")) as Record< + string, + unknown + >; + bindingPayload.updatedAt = new Date(oldBindingUpdatedAt).toISOString(); + await fs.writeFile(bindingPath, `${JSON.stringify(bindingPayload, null, 2)}\n`); + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage( + userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000), + ); + sessionManager.appendMessage( + assistantMessage( + "David Ondrej was mentioned in that prior thread", + oldBindingUpdatedAt + 2_000, + ), + ); + + const firstHarness = createResumeHarness(); + const firstParams = createParams(sessionFile, workspaceDir); + firstParams.prompt = "is the previous message trustworthy?"; + const firstRun = runCodexAppServerAttempt(firstParams); + await firstHarness.waitForMethod("turn/start"); + await firstHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" }); + await firstRun; + + const firstTurnStart = firstHarness.requests.find((request) => request.method === "turn/start"); + const firstInputText = + (firstTurnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0] + ?.text ?? ""; + expect(firstInputText).toContain("OpenClaw assembled context for this turn:"); + expect(firstInputText).toContain("we were discussing the Sonnet leak screenshots"); + expect(firstInputText).toContain("is the previous message trustworthy?"); + + const secondHarness = createResumeHarness(); + const secondParams = createParams(sessionFile, workspaceDir); + secondParams.prompt = "continue from there"; + const secondRun = runCodexAppServerAttempt(secondParams); + await secondHarness.waitForMethod("turn/start"); + await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" }); + await secondRun; + + const secondTurnStart = secondHarness.requests.find( + (request) => request.method === "turn/start", + ); + const secondInputText = + (secondTurnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0] + ?.text ?? ""; + expect(secondInputText).not.toContain("OpenClaw assembled context for this turn:"); + expect(secondInputText).not.toContain("we were discussing the Sonnet leak screenshots"); + expect(secondInputText).not.toContain("is the previous message trustworthy?"); + expect(secondInputText).toContain("continue from there"); + }); + it("passes stable workspace files as Codex developer instructions and keeps MEMORY.md as turn context", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 31416bc5e3a..e99540f079a 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -4655,12 +4655,58 @@ function shouldProjectMirroredHistoryForCodexStart(params: { if (!params.startupBinding?.threadId) { return true; } + if ( + hasUserVisibleHistoryAfterCodexBinding({ + startupBinding: params.startupBinding, + historyMessages: params.historyMessages, + }) + ) { + return true; + } return !areCodexDynamicToolFingerprintsCompatible({ previous: params.startupBinding.dynamicToolsFingerprint, next: params.dynamicToolsFingerprint, }); } +function hasUserVisibleHistoryAfterCodexBinding(params: { + startupBinding: CodexAppServerThreadBinding; + historyMessages: AgentMessage[]; +}): boolean { + const bindingUpdatedAt = Date.parse(params.startupBinding.updatedAt); + if (!Number.isFinite(bindingUpdatedAt)) { + return false; + } + return params.historyMessages.some((message) => { + if (message.role !== "user" && message.role !== "assistant") { + return false; + } + if (isCodexAppServerMirroredTranscriptMessage(message)) { + return false; + } + const timestamp = + typeof message.timestamp === "number" + ? message.timestamp + : typeof message.timestamp === "string" + ? Date.parse(message.timestamp) + : Number.NaN; + return Number.isFinite(timestamp) && timestamp > bindingUpdatedAt; + }); +} + +function isCodexAppServerMirroredTranscriptMessage(message: AgentMessage): boolean { + const record = message as unknown as Record; + const idempotencyKey = record.idempotencyKey; + if (typeof idempotencyKey === "string" && idempotencyKey.startsWith("codex-app-server:")) { + return true; + } + const meta = record["__openclaw"]; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + return false; + } + return typeof (meta as Record).mirrorIdentity === "string"; +} + function readContextEngineThreadBootstrapProjection( projection: ContextEngineProjection | undefined, ): CodexContextEngineThreadBootstrapProjection | undefined { diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 3e3fab2073d..7ed0f7e6dc6 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -952,7 +952,7 @@ describe("sessions", () => { expect(store[mainSessionKey]?.thinkingLevel).toBe("high"); }); - it("updateSessionStore uses the writer-owned mutable cache without disk read or parse", async () => { + it("updateSessionStore uses the writer-owned mutable cache without disk read", async () => { const mainSessionKey = "agent:main:main"; const { storePath } = await createSessionStoreFixture({ prefix: "updateSessionStore-mutable-cache", @@ -968,7 +968,6 @@ describe("sessions", () => { expect(loadSessionStore(storePath)[mainSessionKey]?.thinkingLevel).toBe("low"); const readSpy = vi.spyOn(fsSync, "readFileSync"); - const parseSpy = vi.spyOn(JSON, "parse"); try { await updateSessionStore( storePath, @@ -986,10 +985,8 @@ describe("sessions", () => { ); expect(readSpy).not.toHaveBeenCalled(); - expect(parseSpy).not.toHaveBeenCalled(); } finally { readSpy.mockRestore(); - parseSpy.mockRestore(); } const store = loadSessionStore(storePath, { skipCache: true });