diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8db2938b4720..707b3c75189a 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -1017,6 +1017,81 @@ describe("applyThreadDetailEvent", () => { ]); } }); + + it("excludes the retracted message from a paginated thread window", () => { + const paginatedThread: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("msg-user-loaded"), + role: "user", + text: "Loaded history", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + { + id: MessageId.make("msg-assistant-loaded"), + role: "assistant", + text: "Loaded response", + turnId: TurnId.make("turn-287"), + streaming: false, + createdAt: "2026-04-01T02:00:00.000Z", + updatedAt: "2026-04-01T02:00:00.000Z", + }, + { + id: MessageId.make("msg-user-retracted"), + role: "user", + text: "Retract me", + turnId: null, + streaming: false, + createdAt: "2026-04-01T03:00:00.000Z", + updatedAt: "2026-04-01T03:00:00.000Z", + }, + ], + checkpoints: [ + { + turnId: TurnId.make("turn-287"), + checkpointTurnCount: 287, + checkpointRef: CheckpointRef.make("ref-287"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("msg-assistant-loaded"), + completedAt: "2026-04-01T02:00:00.000Z", + }, + ], + }; + const completedAt = "2026-04-01T04:00:00.000Z"; + + const result = applyThreadDetailEvent(paginatedThread, { + ...baseEventFields, + sequence: 16, + occurredAt: completedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { + threadId: ThreadId.make("thread-1"), + turnCount: 287, + retraction: { + requestId: CommandId.make("cmd-retract-paginated"), + messageId: MessageId.make("msg-user-retracted"), + turnId: null, + firstUserMessage: false, + completedAt, + }, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.id)).toEqual([ + "msg-user-loaded", + "msg-assistant-loaded", + ]); + } + }); }); describe("retraction projection", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 6a38b231596d..0363bcacaa25 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -570,10 +570,15 @@ export function applyThreadDetailEvent( ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); + const excludedMessageIds = + event.payload.retraction === undefined + ? new Set() + : new Set([event.payload.retraction.messageId]); const messages = retainMessagesAfterRevert( thread.messages, retainedTurnIds, event.payload.turnCount, + excludedMessageIds, ); const proposedPlans = pipe( thread.proposedPlans, @@ -754,9 +759,13 @@ function retainMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, turnCount: number, + excludedMessageIds: ReadonlySet, ): OrchestrationMessage[] { const retainedMessageIds = new Set(); for (const message of messages) { + if (excludedMessageIds.has(message.id)) { + continue; + } if (message.role === "system") { retainedMessageIds.add(message.id); continue; @@ -774,6 +783,7 @@ function retainMessagesAfterRevert( .filter( (message) => message.role === "user" && + !excludedMessageIds.has(message.id) && !retainedMessageIds.has(message.id) && (message.turnId === null || retainedTurnIds.has(message.turnId)), ) @@ -794,6 +804,7 @@ function retainMessagesAfterRevert( .filter( (message) => message.role === "assistant" && + !excludedMessageIds.has(message.id) && !retainedMessageIds.has(message.id) && (message.turnId === null || retainedTurnIds.has(message.turnId)), )