mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 05:26:16 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user