Skip to content

Commit 14aa988

Browse files
authored
fix(codex/app-server): stable mirror idempotency to prevent transcript loss (#77046)
* fix(codex/app-server): stable mirror idempotency to prevent transcript loss * Changelog: note codex/app-server transcript mirror dedupe stabilization (#77046)
1 parent be6543c commit 14aa988

5 files changed

Lines changed: 368 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ Docs: https://docs.openclaw.ai
546546
- Plugins/update: keep externalized bundled npm bridge updates on the normal plugin security scanner path instead of granting source-linked official trust without artifact provenance. (#76765) Thanks @Lucenx9.
547547
- Agents/reply context: label replied-to messages as the current user message target in model-visible metadata, so short replies are grounded to their explicit reply target instead of nearby chat history. (#76817) Thanks @obviyus.
548548
- Doctor/plugins: install configured missing official plugins such as Discord and Brave during doctor/update repair, auto-enable repaired provider plugins, preserve config when a download fails, and stop auto-enable from inventing plugin entries when no manifest declares a configured channel. Fixes #76872. Thanks @jack-stormentswe.
549+
- Codex/app-server: stabilize transcript mirror dedupe across re-mirrored turns so reordered snapshots no longer drop reasoning entries or duplicate the assistant reply. Refs #77012. (#77046) Thanks @openperf.
549550

550551
## 2026.5.2
551552

extensions/codex/src/app-server/event-projector.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type JsonValue,
2929
} from "./protocol.js";
3030
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
31+
import { attachCodexMirrorIdentity } from "./transcript-mirror.js";
3132

3233
export type CodexAppServerToolTelemetry = {
3334
didSendViaMessagingTool: boolean;
@@ -185,23 +186,47 @@ export class CodexAppServerEventProjector {
185186
assistantTexts.length > 0
186187
? this.createAssistantMessage(assistantTexts.join("\n\n"))
187188
: undefined;
189+
// Each snapshot entry is tagged with a stable mirror identity of the
190+
// shape `${turnId}:${kind}`. The mirror's idempotency key is derived
191+
// from this identity rather than from snapshot position or content
192+
// hash, so:
193+
// - Re-mirror of the same turn (retry) → same identity → no-op.
194+
// - Re-emit of a prior turn's entry into a later turn's snapshot
195+
// (the cross-turn drift mode named in #77012) → original identity
196+
// is preserved → on-disk key still matches → also a no-op.
197+
// - Two distinct turns where the user repeats verbatim content →
198+
// distinct turnIds → distinct identities → both kept.
199+
const turnId = this.turnId;
188200
const messagesSnapshot: AgentMessage[] = [
189-
{
190-
role: "user",
191-
content: this.params.prompt,
192-
timestamp: Date.now(),
193-
},
201+
attachCodexMirrorIdentity(
202+
{
203+
role: "user",
204+
content: this.params.prompt,
205+
timestamp: Date.now(),
206+
},
207+
`${turnId}:prompt`,
208+
),
194209
];
195210
// Codex owns the canonical thread. These mirror records keep enough local
196211
// context for OpenClaw history, search, and future harness switching.
197212
if (reasoningText) {
198-
messagesSnapshot.push(this.createAssistantMirrorMessage("Codex reasoning", reasoningText));
213+
messagesSnapshot.push(
214+
attachCodexMirrorIdentity(
215+
this.createAssistantMirrorMessage("Codex reasoning", reasoningText),
216+
`${turnId}:reasoning`,
217+
),
218+
);
199219
}
200220
if (planText) {
201-
messagesSnapshot.push(this.createAssistantMirrorMessage("Codex plan", planText));
221+
messagesSnapshot.push(
222+
attachCodexMirrorIdentity(
223+
this.createAssistantMirrorMessage("Codex plan", planText),
224+
`${turnId}:plan`,
225+
),
226+
);
202227
}
203228
if (lastAssistant) {
204-
messagesSnapshot.push(lastAssistant);
229+
messagesSnapshot.push(attachCodexMirrorIdentity(lastAssistant, `${turnId}:assistant`));
205230
}
206231
const turnFailed = this.completedTurn?.status === "failed";
207232
const turnInterrupted = this.completedTurn?.status === "interrupted";

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1822,7 +1822,14 @@ async function mirrorTranscriptBestEffort(params: {
18221822
agentId: params.agentId,
18231823
sessionKey: params.sessionKey,
18241824
messages: params.result.messagesSnapshot,
1825-
idempotencyScope: `codex-app-server:${params.threadId}:${params.turnId}`,
1825+
// Scope is thread-stable. Each entry in `messagesSnapshot` is tagged
1826+
// with a per-turn `attachCodexMirrorIdentity` value carrying its own
1827+
// turnId, so distinct turns produce distinct dedupe keys via the
1828+
// identity (not via the scope). Dropping `turnId` from the scope
1829+
// here is what lets a re-emitted prior-turn entry — which still
1830+
// carries its original `${turnId}:${kind}` identity — collide with
1831+
// its existing on-disk key and be a true no-op.
1832+
idempotencyScope: `codex-app-server:${params.threadId}`,
18261833
config: params.params.config,
18271834
});
18281835
} catch (error) {

0 commit comments

Comments
 (0)