fix(codex): project newer history on app-server resume (#86677)

Project newer external OpenClaw chat history into resumed Codex app-server threads when the saved binding is older than user-visible transcript messages, while filtering Codex-owned mirror records on consecutive resumes.

Thanks @TurboTheTurtle!
This commit is contained in:
Andy Ye
2026-05-26 13:07:07 -07:00
committed by GitHub
parent 3a64dc7623
commit bf0228b5c2
3 changed files with 145 additions and 4 deletions

View File

@@ -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<void>((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");

View File

@@ -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<string, unknown>;
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<string, unknown>).mirrorIdentity === "string";
}
function readContextEngineThreadBootstrapProjection(
projection: ContextEngineProjection | undefined,
): CodexContextEngineThreadBootstrapProjection | undefined {

View File

@@ -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 });