diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index fc9ea4b62268..ac19f98f302b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -115,3 +115,45 @@ describe("projectActivityPayload agent-field survival", () => { expect(projected.payload).toEqual(source.payload); }); }); + +describe("projectActivityPayload legacy retraction compatibility", () => { + function retractionFailure(detail: string): OrchestrationThreadActivity { + return { + id: "retraction-failure-1", + tone: "error", + kind: "turn.retract.failed", + summary: "Message retract failed", + payload: { + requestId: "request-1", + stage: "provider-rollback", + retryable: false, + detail, + }, + turnId: "turn-1", + createdAt: "2026-08-12T18:49:47.397Z", + } as unknown as OrchestrationThreadActivity; + } + + it("marks replayed unavailable-boundary failures silent", () => { + const projected = projectActivityPayload( + retractionFailure( + "Provider adapter validation failed (claudeAgent) in rollbackThreadTo: Provider history has 3 turns, below retained boundary 11.", + ), + ); + + expect(projected.payload).toMatchObject({ + requestId: "request-1", + detail: + "Provider adapter validation failed (claudeAgent) in rollbackThreadTo: Provider history has 3 turns, below retained boundary 11.", + silent: true, + }); + }); + + it("keeps unrelated retraction failures visible", () => { + const source = retractionFailure( + "Provider adapter validation failed (claudeAgent) in rollbackThreadTo: retainedTurnCount must be an integer >= 0.", + ); + + expect(projectActivityPayload(source)).toBe(source); + }); +}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 0c2c6620d885..558f0cfe2c64 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -18,6 +18,35 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +const UNAVAILABLE_RETAINED_BOUNDARY_DETAIL = + /^Provider adapter validation failed \([^)]+\) in rollbackThreadTo: Provider history has \d+ turns, below retained boundary \d+\.$/; + +function normalizeLegacyRetractionFailure( + activity: OrchestrationThreadActivity, +): OrchestrationThreadActivity { + const payload = asRecord(activity.payload); + if ( + activity.kind !== "turn.retract.failed" || + !payload || + typeof payload.detail !== "string" || + !UNAVAILABLE_RETAINED_BOUNDARY_DETAIL.test(payload.detail) + ) { + return activity; + } + + // Boundary failures emitted before stale Esc retractions became silent are + // immutable event history. Normalize them at the wire boundary so every + // projection rebuild keeps the compatibility behavior without rewriting + // the event store or the persisted activity payload. + return { + ...activity, + payload: { + ...payload, + silent: true, + }, + }; +} + export const MAX_PROJECTED_TOOL_RESULT_CHARS = 50_000; const PROJECTED_TOOL_RESULT_TRUNCATION_MARKER = "…[truncated]"; @@ -326,15 +355,16 @@ function projectMcpToolCallData(data: Record): Record