From c9709f63f75fc749d942fade9d3300f4a83a484c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:47:15 +0000 Subject: [PATCH 1/3] fix(dashboard): Settle stuck inline sends and report a late steer An inline send stayed in the transcript tail when history never showed its own Message. This happens when a Turn joins it into an earlier send, or when another participant deletes it from the queue. The row then kept the 2s poll on and moved every later send to the queue. The outbox now treats Turn input ids as read. It also drops an inline row that the mailbox listed and then lost, because the snapshot reads the mailbox before history. Steer now shows "Junior already read that message." when the steer lands too late, the same as edit. Co-Authored-By: Aditya Mathur --- .../client/conversations/ConversationPage.tsx | 14 ++++- .../conversations/conversationOutbox.ts | 56 +++++++++++++++++ .../src/client/conversations/queries.ts | 30 +++------ .../tests/conversation-outbox.test.ts | 61 +++++++++++++++++++ 4 files changed, 138 insertions(+), 23 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 07b2a0e335..119aa0c28d 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -707,7 +707,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { // Junior can read the message before the cancel lands. Then it is // already in the Conversation and there is nothing to edit. if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) { - setQueueActionError("Junior already read that message."); + setQueueActionError(ALREADY_READ_ERROR); return; } const restored = { images, text: message.text ?? "" }; @@ -736,13 +736,20 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { const onSteerMessage = useCallback((message: ConversationMailboxMessage) => { if (steerPendingMessagesRef.current.isPending) return; setQueueActionError(undefined); + const inboundMessageId = message.inboundMessageId; steerPendingMessagesRef.current.mutate( - { inboundMessageIds: [message.inboundMessageId] }, + { inboundMessageIds: [inboundMessageId] }, { onError: () => setQueueActionError( "Could not send the queued message now. Try again.", ), + // Like edit, a steer can land after Junior read the message. + onSuccess: (result) => { + if (!result.steeredInboundMessageIds.includes(inboundMessageId)) { + setQueueActionError(ALREADY_READ_ERROR); + } + }, }, ); }, []); @@ -850,3 +857,6 @@ function committedMessageIds( } const EMPTY_MESSAGE_IDS: readonly string[] = []; + +// A queue action can land after Junior read the message. +const ALREADY_READ_ERROR = "Junior already read that message."; diff --git a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts index a23f51f7f0..bb4da02d53 100644 --- a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts +++ b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts @@ -1,6 +1,7 @@ import type { ActorIdentity, ConversationPendingMessage, + ConversationReportEvent, InputImage, } from "@sentry/junior/api/schema"; @@ -14,6 +15,8 @@ export type ConversationOutboxMessage = { * the row in place, so it does not pass through the queue first. */ inline?: boolean; + /** A server snapshot listed this inline row in the mailbox. */ + inlineSeenQueued?: boolean; message: string; images?: InputImage[]; messageId: string; @@ -168,6 +171,59 @@ export function isInlineOutboxMessage( return Boolean(message.inline) && message.status !== "failed"; } +/** Message ids that history shows as read, including joined Turn input. */ +function readMessageIds( + events: readonly ConversationReportEvent[], +): Set { + const ids = new Set(); + for (const event of events) { + if (event.data.type === "message") ids.add(event.data.messageId); + // A Turn joins several queued sends into the Message of the first one. + if (event.data.type === "turn_lifecycle" && event.data.state === "started") + for (const id of event.data.inputMessageIds ?? []) ids.add(id); + } + return ids; +} + +/** + * Drop local sends that one server snapshot already shows. + * + * A queued row leaves when the mailbox or history has it. An inline row stays + * until history has it. The snapshot reads the mailbox before history, so an + * inline row that the mailbox listed and then lost is in history or was + * removed, for example by another participant. + */ +export function settleConversationOutboxMessages( + current: ConversationOutboxMessage[], + snapshot: { + events: readonly ConversationReportEvent[]; + queued: readonly ConversationPendingMessage[]; + }, +): ConversationOutboxMessage[] { + const queuedIds = new Set( + snapshot.queued.map((message) => message.messageId), + ); + const readIds = readMessageIds(snapshot.events); + const settle = ( + message: ConversationOutboxMessage, + ): ConversationOutboxMessage | undefined => { + if (readIds.has(message.messageId)) return undefined; + const queued = queuedIds.has(message.messageId); + if (!isInlineOutboxMessage(message)) return queued ? undefined : message; + if (queued) { + return message.inlineSeenQueued + ? message + : { ...message, inlineSeenQueued: true }; + } + return message.inlineSeenQueued ? undefined : message; + }; + const next = current.flatMap((message) => settle(message) ?? []); + const unchanged = + next.length === current.length && + next.every((message, index) => message === current[index]); + return unchanged ? current : next; +} + /** * Split pending rows between the queue and the transcript. * diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index ddf47067b9..d52898b328 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -43,10 +43,10 @@ import { conversationOutboxMessageForSubmit, conversationOutboxQueryKey, failConversationOutboxMessage, - isInlineOutboxMessage, mergeConversationMailboxMessages, acceptConversationOutboxMessage, reuseConversationMailboxMessages, + settleConversationOutboxMessages, splitInlineMailboxMessages, upsertConversationOutboxMessage, type ConversationMailboxMessage, @@ -722,30 +722,18 @@ export function useConversationData(conversationId: string | undefined) { ? deferredDetail : detail.data; const mailbox = detailData?.mailbox; - // Accept is not visibility. Keep a local row until either server resource - // contains its real Message id. History and mailbox arrive in one render. + // Accept is not visibility. Keep a local row until the server snapshot + // shows it. History and mailbox arrive in one render. useEffect(() => { if (!mailbox || !detailData) return; - const queuedIds = new Set( - mailbox.messages.map((message) => message.messageId), - ); - const historyIds = new Set(); - for (const event of detailData.events) { - if (event.data.type === "message") historyIds.add(event.data.messageId); - } queryClient.setQueryData( conversationOutboxQueryKey(conversationId), - (current) => { - // An inline row stays until history has it. The mailbox copy alone - // would put it back in the queue. - const next = current?.filter( - (message) => - !historyIds.has(message.messageId) && - (isInlineOutboxMessage(message) || - !queuedIds.has(message.messageId)), - ); - return next?.length === current?.length ? current : next; - }, + (current) => + current && + settleConversationOutboxMessages(current, { + events: detailData.events, + queued: mailbox.messages, + }), ); }, [conversationId, detailData, mailbox, outbox.data, queryClient]); // The title is generated after the first message, and only the open diff --git a/packages/junior-dashboard/tests/conversation-outbox.test.ts b/packages/junior-dashboard/tests/conversation-outbox.test.ts index 8b5a37a6ec..c9be070259 100644 --- a/packages/junior-dashboard/tests/conversation-outbox.test.ts +++ b/packages/junior-dashboard/tests/conversation-outbox.test.ts @@ -5,6 +5,7 @@ import { failConversationOutboxMessage, mailboxMessageFromOutbox, mergeConversationMailboxMessages, + settleConversationOutboxMessages, splitInlineMailboxMessages, upsertConversationOutboxMessage, } from "../src/client/conversations/conversationOutbox"; @@ -190,4 +191,64 @@ describe("conversation outbox", () => { queued: merged, }); }); + + it("keeps inline sends until history reads them or the queue drops them", () => { + const inline = conversationOutboxMessageForSubmit({ + idempotencyKey: "attempt-1", + inline: true, + messageId: "accepted-1", + message: "Continue in Junior", + now: "2026-01-01T00:00:00.000Z", + }); + const joined = conversationOutboxMessageForSubmit({ + idempotencyKey: "attempt-2", + inline: true, + messageId: "accepted-2", + message: "And the rollback owner", + now: "2026-01-01T00:00:01.000Z", + }); + const queuedCopy = { + ...mailboxMessageFromOutbox(inline), + clientStatus: undefined, + idempotencyKey: undefined, + }; + const outbox = [inline, joined]; + + // Nothing on the server yet: the rows stay as they are. + expect( + settleConversationOutboxMessages(outbox, { events: [], queued: [] }), + ).toBe(outbox); + + // The mailbox lists the first send. It stays inline and is marked seen. + const seen = settleConversationOutboxMessages(outbox, { + events: [], + queued: [queuedCopy], + }); + expect(seen).toEqual([{ ...inline, inlineSeenQueued: true }, joined]); + + // A Turn joined the second send into the first Message, so only its + // started event has the second id. + expect( + settleConversationOutboxMessages(outbox, { + events: [ + { + seq: 1, + createdAt: "2026-01-01T00:00:02.000Z", + data: { + type: "turn_lifecycle", + turnId: "turn-1", + state: "started", + inputMessageIds: ["accepted-1", "accepted-2"], + }, + }, + ], + queued: [], + }), + ).toEqual([]); + + // Another participant removed the seen send from the queue. + expect( + settleConversationOutboxMessages(seen, { events: [], queued: [] }), + ).toEqual([joined]); + }); }); From 45e09aa0d9d2867a99b0ba2afe162dcae9f744ce Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:55:32 +0000 Subject: [PATCH 2/3] fix(dashboard): Keep a joined inline send while the mailbox lists it A Turn records its input ids before the mailbox drops them. A joined send has only its Turn input id, so dropping the inline row then let the mailbox copy show in the queue until the next poll. Count Turn input ids as read only after the mailbox no longer lists the send. --- .../conversations/conversationOutbox.ts | 30 ++++++++----- .../tests/conversation-outbox.test.ts | 42 +++++++++++++------ 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts index bb4da02d53..8e935c0a34 100644 --- a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts +++ b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts @@ -171,18 +171,19 @@ export function isInlineOutboxMessage( return Boolean(message.inline) && message.status !== "failed"; } -/** Message ids that history shows as read, including joined Turn input. */ -function readMessageIds( - events: readonly ConversationReportEvent[], -): Set { - const ids = new Set(); +/** Ids of history Messages and of the input that started each Turn. */ +function historyMessageIds(events: readonly ConversationReportEvent[]): { + messageIds: Set; + turnInputIds: Set; +} { + const messageIds = new Set(); + const turnInputIds = new Set(); for (const event of events) { - if (event.data.type === "message") ids.add(event.data.messageId); - // A Turn joins several queued sends into the Message of the first one. + if (event.data.type === "message") messageIds.add(event.data.messageId); if (event.data.type === "turn_lifecycle" && event.data.state === "started") - for (const id of event.data.inputMessageIds ?? []) ids.add(id); + for (const id of event.data.inputMessageIds ?? []) turnInputIds.add(id); } - return ids; + return { messageIds, turnInputIds }; } /** @@ -203,12 +204,19 @@ export function settleConversationOutboxMessages( const queuedIds = new Set( snapshot.queued.map((message) => message.messageId), ); - const readIds = readMessageIds(snapshot.events); + const { messageIds, turnInputIds } = historyMessageIds(snapshot.events); const settle = ( message: ConversationOutboxMessage, ): ConversationOutboxMessage | undefined => { - if (readIds.has(message.messageId)) return undefined; const queued = queuedIds.has(message.messageId); + // A Turn joins several sends into the Message of the first one, so a + // joined send has only its Turn input id. The Turn starts before the + // mailbox drops the send. Until then, keep the row, or the mailbox copy + // would show in the queue. + const read = + messageIds.has(message.messageId) || + (!queued && turnInputIds.has(message.messageId)); + if (read) return undefined; if (!isInlineOutboxMessage(message)) return queued ? undefined : message; if (queued) { return message.inlineSeenQueued diff --git a/packages/junior-dashboard/tests/conversation-outbox.test.ts b/packages/junior-dashboard/tests/conversation-outbox.test.ts index c9be070259..a649bfad6a 100644 --- a/packages/junior-dashboard/tests/conversation-outbox.test.ts +++ b/packages/junior-dashboard/tests/conversation-outbox.test.ts @@ -227,21 +227,37 @@ describe("conversation outbox", () => { expect(seen).toEqual([{ ...inline, inlineSeenQueued: true }, joined]); // A Turn joined the second send into the first Message, so only its - // started event has the second id. + // started event has the second id. The Turn starts before the mailbox + // drops the send, so the row stays inline until then. + const turnStarted = [ + { + seq: 1, + createdAt: "2026-01-01T00:00:02.000Z", + data: { + type: "turn_lifecycle" as const, + turnId: "turn-1", + state: "started" as const, + inputMessageIds: ["accepted-1", "accepted-2"], + }, + }, + ]; + const joinedCopy = { + ...mailboxMessageFromOutbox(joined), + clientStatus: undefined, + idempotencyKey: undefined, + }; expect( settleConversationOutboxMessages(outbox, { - events: [ - { - seq: 1, - createdAt: "2026-01-01T00:00:02.000Z", - data: { - type: "turn_lifecycle", - turnId: "turn-1", - state: "started", - inputMessageIds: ["accepted-1", "accepted-2"], - }, - }, - ], + events: turnStarted, + queued: [queuedCopy, joinedCopy], + }), + ).toEqual([ + { ...inline, inlineSeenQueued: true }, + { ...joined, inlineSeenQueued: true }, + ]); + expect( + settleConversationOutboxMessages(outbox, { + events: turnStarted, queued: [], }), ).toEqual([]); From 25d3c9720fde3c444137d9b10e71f24928ee4c47 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:03:07 +0000 Subject: [PATCH 3/3] fix(dashboard): Settle a removed inline send by accept time An inline send left the outbox only when this tab saw it in the mailbox first. A background tab, a removal between polls, or a restored older snapshot could still leave it stuck or drop it too early. Each snapshot now records when its mailbox read started, and each outbox row records when its accept arrived. A mailbox read that started after the accept and has neither copy means the send was removed. Edit and steer now name every reason that no id comes back, instead of saying that Junior read the message. Co-Authored-By: Aditya Mathur --- .../e2e/conversation-send.spec.ts | 2 +- .../client/conversations/ConversationPage.tsx | 17 +-- .../conversations/conversationOutbox.ts | 27 ++-- .../src/client/conversations/queries.ts | 8 +- .../tests/conversation-outbox.test.ts | 118 +++++++++++------- 5 files changed, 105 insertions(+), 67 deletions(-) diff --git a/packages/junior-dashboard/e2e/conversation-send.spec.ts b/packages/junior-dashboard/e2e/conversation-send.spec.ts index d594fe47a9..f35d22cbe0 100644 --- a/packages/junior-dashboard/e2e/conversation-send.spec.ts +++ b/packages/junior-dashboard/e2e/conversation-send.spec.ts @@ -869,7 +869,7 @@ test("edits a queued message back into the composer", async ({ await pending.getByRole("button", { name: "Edit queued message" }).click(); await expect( - page.getByText("Junior already read that message."), + page.getByText("That message was already read or removed."), ).toBeVisible(); await expect(composer).toHaveValue( `${queuedMessage.text}\nDraft in progress`, diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 119aa0c28d..996bda0555 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -704,10 +704,10 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { inboundMessageIds: [inboundMessageId], receivedBefore, }); - // Junior can read the message before the cancel lands. Then it is - // already in the Conversation and there is nothing to edit. + // Junior can read the message before the cancel lands, or someone + // else can remove it. Then there is nothing to edit. if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) { - setQueueActionError(ALREADY_READ_ERROR); + setQueueActionError("That message was already read or removed."); return; } const restored = { images, text: message.text ?? "" }; @@ -744,10 +744,14 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { setQueueActionError( "Could not send the queued message now. Try again.", ), - // Like edit, a steer can land after Junior read the message. + // No id comes back when the message is not a queued `defer` message + // anymore. Junior read it, someone removed it, or another tab + // steered it. The message names all three. onSuccess: (result) => { if (!result.steeredInboundMessageIds.includes(inboundMessageId)) { - setQueueActionError(ALREADY_READ_ERROR); + setQueueActionError( + "That message was already read, steered, or removed.", + ); } }, }, @@ -857,6 +861,3 @@ function committedMessageIds( } const EMPTY_MESSAGE_IDS: readonly string[] = []; - -// A queue action can land after Junior read the message. -const ALREADY_READ_ERROR = "Junior already read that message."; diff --git a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts index 8e935c0a34..d75c9db934 100644 --- a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts +++ b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts @@ -15,8 +15,8 @@ export type ConversationOutboxMessage = { * the row in place, so it does not pass through the queue first. */ inline?: boolean; - /** A server snapshot listed this inline row in the mailbox. */ - inlineSeenQueued?: boolean; + /** Client time when the accept response arrived. */ + acceptedAtMs?: number; message: string; images?: InputImage[]; messageId: string; @@ -190,14 +190,17 @@ function historyMessageIds(events: readonly ConversationReportEvent[]): { * Drop local sends that one server snapshot already shows. * * A queued row leaves when the mailbox or history has it. An inline row stays - * until history has it. The snapshot reads the mailbox before history, so an - * inline row that the mailbox listed and then lost is in history or was - * removed, for example by another participant. + * until history has it. The server writes the mailbox before it accepts a + * send, and the snapshot reads the mailbox before history. So when a mailbox + * read that started after the accept has neither copy, the send was removed, + * for example by another participant. */ export function settleConversationOutboxMessages( current: ConversationOutboxMessage[], snapshot: { events: readonly ConversationReportEvent[]; + /** Client time when the snapshot started its mailbox read. */ + mailboxReadAtMs?: number; queued: readonly ConversationPendingMessage[]; }, ): ConversationOutboxMessage[] { @@ -218,12 +221,12 @@ export function settleConversationOutboxMessages( (!queued && turnInputIds.has(message.messageId)); if (read) return undefined; if (!isInlineOutboxMessage(message)) return queued ? undefined : message; - if (queued) { - return message.inlineSeenQueued - ? message - : { ...message, inlineSeenQueued: true }; - } - return message.inlineSeenQueued ? undefined : message; + const removed = + !queued && + message.acceptedAtMs !== undefined && + snapshot.mailboxReadAtMs !== undefined && + snapshot.mailboxReadAtMs > message.acceptedAtMs; + return removed ? undefined : message; }; const next = current.flatMap((message) => settle(message) ?? []); const unchanged = @@ -286,7 +289,7 @@ export function acceptConversationOutboxMessage( ): ConversationOutboxMessage[] { return (current ?? []).map((message) => message.idempotencyKey === idempotencyKey - ? { ...message, messageId, status: "accepted" } + ? { ...message, acceptedAtMs: Date.now(), messageId, status: "accepted" } : message, ); } diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index d52898b328..ae8ab4cad7 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -141,6 +141,8 @@ const FAST_POLL_WINDOW_MS = 6_000; type ConversationSnapshot = ConversationDetailReport & { mailbox?: ConversationPendingMessagesReport; + /** Client time when this snapshot started its mailbox read. */ + mailboxReadAtMs?: number; detailEtag?: string; }; @@ -150,9 +152,10 @@ async function readConversationSnapshot( signal?: AbortSignal, previous?: ConversationSnapshot, ): Promise { + const mailboxReadAtMs = Date.now(); const mailbox = await readConversationPendingMessages(conversationId, signal); const detail = await readConversationData(conversationId, signal, previous); - return { ...detail, mailbox }; + return { ...detail, mailbox, mailboxReadAtMs }; } /** Refresh history and mailbox as one visible snapshot, including idle input. */ @@ -232,6 +235,7 @@ export function useCreateConversation() { inline: true, messageId: accepted.messageId, }), + acceptedAtMs: Date.now(), status: "accepted", }), ); @@ -282,6 +286,7 @@ export function useForkConversation(sourceConversationId: string) { inline: true, messageId: accepted.messageId, }), + acceptedAtMs: Date.now(), status: "accepted", }), ); @@ -732,6 +737,7 @@ export function useConversationData(conversationId: string | undefined) { current && settleConversationOutboxMessages(current, { events: detailData.events, + mailboxReadAtMs: detailData.mailboxReadAtMs, queued: mailbox.messages, }), ); diff --git a/packages/junior-dashboard/tests/conversation-outbox.test.ts b/packages/junior-dashboard/tests/conversation-outbox.test.ts index a649bfad6a..37f22e492b 100644 --- a/packages/junior-dashboard/tests/conversation-outbox.test.ts +++ b/packages/junior-dashboard/tests/conversation-outbox.test.ts @@ -193,42 +193,40 @@ describe("conversation outbox", () => { }); it("keeps inline sends until history reads them or the queue drops them", () => { - const inline = conversationOutboxMessageForSubmit({ - idempotencyKey: "attempt-1", - inline: true, - messageId: "accepted-1", - message: "Continue in Junior", - now: "2026-01-01T00:00:00.000Z", - }); - const joined = conversationOutboxMessageForSubmit({ - idempotencyKey: "attempt-2", - inline: true, - messageId: "accepted-2", - message: "And the rollback owner", - now: "2026-01-01T00:00:01.000Z", + const accepted = (messageId: string, message: string) => ({ + ...conversationOutboxMessageForSubmit({ + idempotencyKey: messageId, + inline: true, + messageId, + message, + now: "2026-01-01T00:00:00.000Z", + }), + acceptedAtMs: 1_000, + status: "accepted" as const, }); - const queuedCopy = { - ...mailboxMessageFromOutbox(inline), + const serverCopy = (message: ReturnType) => ({ + ...mailboxMessageFromOutbox(message), clientStatus: undefined, idempotencyKey: undefined, - }; - const outbox = [inline, joined]; + }); + const inline = accepted("accepted-1", "Continue in Junior"); + const joined = accepted("accepted-2", "And the rollback owner"); + const queuedRow = { ...accepted("queued-1", "Queued"), inline: undefined }; + const outbox = [inline, joined, queuedRow]; - // Nothing on the server yet: the rows stay as they are. + // A mailbox read from before the accept proves nothing. The queued row + // leaves once the mailbox lists it. expect( - settleConversationOutboxMessages(outbox, { events: [], queued: [] }), - ).toBe(outbox); - - // The mailbox lists the first send. It stays inline and is marked seen. - const seen = settleConversationOutboxMessages(outbox, { - events: [], - queued: [queuedCopy], - }); - expect(seen).toEqual([{ ...inline, inlineSeenQueued: true }, joined]); + settleConversationOutboxMessages(outbox, { + events: [], + mailboxReadAtMs: 900, + queued: [serverCopy(queuedRow)], + }), + ).toEqual([inline, joined]); // A Turn joined the second send into the first Message, so only its // started event has the second id. The Turn starts before the mailbox - // drops the send, so the row stays inline until then. + // drops the send, so the row stays inline and out of the queue. const turnStarted = [ { seq: 1, @@ -240,31 +238,61 @@ describe("conversation outbox", () => { inputMessageIds: ["accepted-1", "accepted-2"], }, }, + { + seq: 2, + createdAt: "2026-01-01T00:00:02.000Z", + data: { + type: "message" as const, + messageId: "accepted-1", + role: "user" as const, + text: "Continue in Junior\nAnd the rollback owner", + }, + }, ]; - const joinedCopy = { - ...mailboxMessageFromOutbox(joined), - clientStatus: undefined, - idempotencyKey: undefined, - }; - expect( - settleConversationOutboxMessages(outbox, { - events: turnStarted, - queued: [queuedCopy, joinedCopy], - }), - ).toEqual([ - { ...inline, inlineSeenQueued: true }, - { ...joined, inlineSeenQueued: true }, + const server = [serverCopy(inline), serverCopy(joined)]; + const whileQueued = settleConversationOutboxMessages([inline, joined], { + events: turnStarted, + mailboxReadAtMs: 2_000, + queued: server, + }); + expect(whileQueued).toEqual([joined]); + const split = splitInlineMailboxMessages( + mergeConversationMailboxMessages(server, whileQueued), + server, + whileQueued, + ); + expect(split.inline.map((message) => message.messageId)).toEqual([ + "accepted-2", ]); + expect(split.queued.map((message) => message.messageId)).toEqual([ + "accepted-1", + ]); + // History has a Message for the first send, so the queue hides it. + + // After the ack, the Turn input id settles the joined send. expect( - settleConversationOutboxMessages(outbox, { + settleConversationOutboxMessages([joined], { events: turnStarted, + mailboxReadAtMs: 3_000, queued: [], }), ).toEqual([]); - // Another participant removed the seen send from the queue. + // A mailbox read after the accept has neither copy: someone removed it. + const unchanged = [inline]; expect( - settleConversationOutboxMessages(seen, { events: [], queued: [] }), - ).toEqual([joined]); + settleConversationOutboxMessages(unchanged, { + events: [], + mailboxReadAtMs: 1_000, + queued: [], + }), + ).toBe(unchanged); + expect( + settleConversationOutboxMessages(unchanged, { + events: [], + mailboxReadAtMs: 1_001, + queued: [], + }), + ).toEqual([]); }); });