diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 57f2fa410ff..4b53ee81556 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -25,8 +25,8 @@ const RESUME_KEY = '\u0000resume' export type MobileNativeChatQueuedMessagesProps = { cards?: MobileQueuedMessageCard[] - /** Steer for a waiting card, the paused queue's included; plain Send for a card whose own send - * failed, or a returned one. */ + /** Steer for a waiting card, the paused queue's included; plain Send for a card held on its own + * (its send failed, or the host kept it unsent), or a returned one. */ onSend?: (messageId: string) => Promise onDelete?: (messageId: string) => Promise /** Copy the card's text into the composer, then delete the card. */ diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 3e037180530..90ebecb42eb 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -67,8 +67,8 @@ export type MobileNativeChatController = { }) => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void - /** Host-held queued drafts shown as cards above the composer (structured lane, - * capable host only; empty otherwise). */ + /** Host-held queued drafts shown as cards above the composer (structured lane; any host + * that publishes them). */ nativeChatQueued: MobileStructuredQueuedMessageControls nativeChatFilePaths: string[] loadNativeChatFiles: (query: string) => void diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 13604344789..01b8c7d4bc2 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -23,7 +23,7 @@ export type MobileNativeChatSendOrigin = { baselineTailMessageId: string | null baselineResolved: boolean /** Queued-draft cards already on screen at send time, so an earlier identical - * card cannot confirm this send. Structured lane on a queue-capable host only. */ + * card cannot confirm this send. Structured lane, on any host that publishes cards. */ baselineQueuedMessageIds?: readonly string[] } diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 44d1e0b9729..987d9aeefd4 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' +import { + QUEUED_MESSAGE_PAUSED_KEPT, + QUEUED_MESSAGE_PAUSED_SEND_FAILED +} from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import { mobileQueueHasResumableCard, @@ -99,6 +102,26 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) + // A kept card is held on its own, like a failed one: the drain goes past it. + const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }) + expect(resumable([kept])).toBe(false) + expect(resumable([kept, waiting])).toBe(true) + }) + + // The host kept it unsent across a restart or a close; the cards behind it are not held by it. + it('captions a kept card as not sent yet, and leaves the cards behind it plainly queued', () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }), + draft({ messageId: 'b', position: 2 }) + ], + [], + { pendingPrompt: false } + ) + expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([ + { caption: 'Not sent yet — tap Send to send it', needsAttention: false }, + { caption: null, needsAttention: false } + ]) }) it('words the paused queue by reason, and one this build does not know as a plain pause', () => { diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c..843292e4421 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -8,6 +8,7 @@ import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-sessio import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' import { + QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuePause @@ -48,11 +49,15 @@ function returnedCaption( ) } -/** One card's own hold: only a failed conversion; the queue's pause is the list's first row. */ +/** One card's own hold: a failed conversion, or a send the host kept; the queue's pause is the + * list's first row. */ function pausedCaption(reason: string | undefined): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return "Couldn't send — tap Send to retry" } + if (reason === QUEUED_MESSAGE_PAUSED_KEPT) { + return 'Not sent yet — tap Send to send it' + } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' } diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 8431e32a7aa..278142b271c 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -125,6 +125,34 @@ describe('mobileStructuredSendDelivery', () => { }) }) + it('answers a send the host kept as a card like a queued one, first send or replay', () => { + // The card shows the text, so neither an error nor a composer hand-back may repeat it. + const kept: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: { + clientMessageId: 'msg-1', + submission: { + clientMessageId: 'msg-1', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this was sent.', + submittedAt: 10, + resolvedAt: 10, + keptAsQueuedMessageId: 'msg-1' + } + } + } + for (const retained of [false, true]) { + expect(mobileStructuredSendDelivery(kept, retained)).toEqual({ + outcome: 'queued', + operationIdSpent: true, + error: null + }) + } + }) + it('shows a provider content rejection verbatim', () => { expect( mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index f20dc097188..bc0db996a93 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -20,6 +20,7 @@ // and keeping the id would only refuse every later send of the same text: // a host that refuses the replay's request shape itself (an older host's // strict schema turning `delivery` away), and an id the host has expired. +// A rejection the host kept as a card answers as `queued`: the card holds the text. // unknown — the one answer that KEEPS its id, whether it came from the host or // from an ack-loss on the way back. The message may be with the provider, so // the retry has to stay a replay. Rotating here is what sent one message to a @@ -96,6 +97,11 @@ export function mobileStructuredSendDelivery( if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } + if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) { + // The host kept it as a card, which holds the text: no error, and nothing handed back to the + // composer, so the words never show twice. + return { outcome: 'queued', operationIdSpent: true, error: null } + } if (submission.dispatchState === 'rejected') { return { outcome: 'rejected', diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 277beb17d75..fe7f7f1eb14 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -568,12 +568,28 @@ describe('mobile structured queued messages', () => { expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['same-id']) }) - it('shows no cards from an incapable host even if a list arrives', async () => { - await mountSession( - LEGACY, - snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + // A host that does not queue sends still keeps a message it accepted and never sent across a + // restart or a close, and publishes it as a card; only queueing a new send is gated. + it('shows the cards a host that does not queue sends publishes', async () => { + await mountSession(LEGACY) + act(() => + listener?.( + batchEvent( + [ + queuedDraft({ messageId: 'kept-1', paused: true, pausedReason: 'kept' }), + queuedDraft({ messageId: 'behind', position: 2 }) + ], + [], + { reason: 'restarted' } + ) + ) ) - expect(hook!.queued.cards).toEqual([]) + expect(hook!.queued.cards.map(({ messageId, caption }) => ({ messageId, caption }))).toEqual([ + { messageId: 'kept-1', caption: 'Not sent yet — tap Send to send it' }, + { messageId: 'behind', caption: null } + ]) + // The kept card is held on its own, so Resume would send the card behind it. + expect(hook!.queued.pause).toEqual({ reason: 'restarted' }) }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 7bec053c9d6..1e1658511bc 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -61,7 +61,7 @@ type StructuredMobileSession = ReturnType Promise respondQuestion: (answer: string) => Promise cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise - /** The queued-draft cards and their actions; empty and inert off capable hosts. */ + /** The queued-draft cards and their actions, from any host that publishes them. */ queued: MobileStructuredQueuedMessageControls } @@ -97,7 +97,7 @@ export function useMobileStructuredAgentSession(args: { onSendError, hostSupport } = args - // Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop. + // Only a host that queues sends gets the delivery field; any host's published cards show. const queueCapable = hostSupport?.queuedMessages === true const promptCancelSupported = hostSupport?.promptCancel ?? null const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null @@ -196,7 +196,6 @@ export function useMobileStructuredAgentSession(args: { [state.items] ) const queued = useMobileStructuredQueuedMessageControls({ - queueCapable, sessionKey, queuedMessages, queuePause, diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index 4e033c6dd97..f65ecf2d46e 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -1,8 +1,10 @@ // The queued-draft surface the structured session exposes: cards derived from -// the published list and the Send-now / Delete / Edit actions. All of it is -// gated on the host capability — an incapable host gets no cards and no new -// fields. Nothing here is durable: the host owns the queue, and the published -// list is the only truth a card action ever needs. +// the published list and the Send-now / Delete / Edit actions. Shown whatever +// the queue capability says: a host that does not queue sends still publishes a +// message it kept across a restart or a close, and only queueing a new send is +// gated. A host older than the queue publishes no list, so shows no cards. +// Nothing here is durable: the host owns the queue, and the published list is +// the only truth a card action ever needs. import { useCallback, useMemo } from 'react' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' @@ -26,7 +28,7 @@ import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent- export type MobileQueuedMessageEdit = (messageId: string, onCopied?: () => void) => Promise export type MobileStructuredQueuedMessageControls = { - /** Host-held drafts as cards above the composer; empty off capable hosts. */ + /** Host-held drafts as cards above the composer. */ cards: MobileQueuedMessageCard[] /** Send-now: dispatch this draft into or ahead of the running turn. */ send: (messageId: string) => Promise @@ -43,7 +45,6 @@ export type MobileStructuredQueuedMessageControls = { } export function useMobileStructuredQueuedMessageControls(args: { - queueCapable: boolean sessionKey: string queuedMessages: MobileQueuedMessageFeed queuePause: MobileQueuePause @@ -63,7 +64,6 @@ export function useMobileStructuredQueuedMessageControls(args: { onActionResolved, onSendError, pendingPrompt, - queueCapable, queuedMessages, queuePause, sessionKey, @@ -71,13 +71,11 @@ export function useMobileStructuredQueuedMessageControls(args: { } = args const cards = useMemo( () => - queueCapable - ? mobileQueuedMessageCards(queuedMessages, submissions, { - pendingPrompt, - queuePaused: queuePause !== null - }) - : [], - [pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] + mobileQueuedMessageCards(queuedMessages, submissions, { + pendingPrompt, + queuePaused: queuePause !== null + }), + [pendingPrompt, queuePause, queuedMessages, submissions] ) const resolved = useCallback( (accepted: boolean): boolean => { @@ -160,6 +158,6 @@ export function useMobileStructuredQueuedMessageControls(args: { [mutate, resolved] ) // The header shows only while Resume would send something, as on desktop. - const pause = queueCapable && mobileQueueHasResumableCard(cards) ? queuePause : null + const pause = mobileQueueHasResumableCard(cards) ? queuePause : null return { cards, send, delete: deleteDraft, edit, pause, resume, sessionKey } } diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 3625982a24f..519a62fb3e1 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -35,6 +35,13 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } + if ( + row.state === 'rejected' && + typeof row.keptAsQueuedMessageId === 'string' && + row.keptAsQueuedMessageId.length > 0 + ) { + submission.keptAsQueuedMessageId = row.keptAsQueuedMessageId + } if (row.state === 'rejected' && row.answeredInTurn !== undefined) { submission.answeredInTurn = readAnsweredTurn(row.answeredInTurn) } else { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts index 7f9711151fc..7c6deabef40 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -10,27 +10,34 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -function settle(kind: SubmissionRejectionKind) { - return rejectedDraftSettlement( - agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) - ) +function settle(kind: SubmissionRejectionKind, origin?: 'client' | 'host') { + return rejectedDraftSettlement({ + ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }), + origin + }) } describe('what a rejection does to the draft it was consumed from', () => { it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { - expect(settle('cancelled')).toEqual({ state: 'waiting' }) + expect(settle('cancelled')).toEqual({ state: 'waiting', kept: false }) expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ - state: 'waiting' + state: 'waiting', + kept: false }) + expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false }) }) - it('a restart or close before hand-over sends it back to waiting too', () => { - for (const kind of ['hostRestarted', 'chatClosed', 'notDelivered'] as const) { - expect(settle(kind)).toEqual({ state: 'waiting' }) + // A Send the person asked for and the host never handed over waits for them; the queue's own + // hand-off, or one from a build that recorded no origin, waits under the queue's pause. + it('a restart or close before hand-over sends it back to waiting, kept only when the person sent it', () => { + for (const kind of ['hostRestarted', 'chatClosed'] as const) { + expect(settle(kind, 'client')).toEqual({ state: 'waiting', kept: true }) + expect(settle(kind, 'host')).toEqual({ state: 'waiting', kept: false }) + expect(settle(kind)).toEqual({ state: 'waiting', kept: false }) } - expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ - state: 'waiting' - }) + expect( + rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED, origin: 'client' }) + ).toEqual({ state: 'waiting', kept: true }) }) it('a failure returns the card for the user to act on', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index 7e1d6949bf3..6b3e53f36b3 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -30,22 +30,27 @@ export function consumedSubmissionWasRejected( } /** What a consumed draft becomes when its submission is rejected. */ -export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' } +export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean } /** - * Where no one failed the user — a Stop withdrew it, or a restart or close - * interrupted it before hand-over — the draft goes back to waiting at its own - * position, under whatever pauses the queue: the Stop's own pause, or the - * restart's, derived from the host instance. A returned card would block the - * drafts behind it on a failure that never happened. A failure returns the - * card with its refusal for the user to act on. + * Where no one failed the user, the draft goes back to waiting at its own position, under whatever + * pauses the queue: a Stop's, or the restart's, derived from the host instance. A Send the person + * asked for (`origin` client) that a restart or a close cut short is kept (`kept`) until they send + * it again, as the host keeps every message a person sent and it never handed over; the queue's + * own hand-off is not theirs, so it waits as any queued card does: under the restart's pause after + * a restart, and plainly queued after a close in the same process. A returned card would block the drafts + * behind it on a failure that never happened. A failure returns the card with its refusal for the + * user to act on. */ export function rejectedDraftSettlement( - rejection: Pick & { rejection?: unknown } + rejected: Pick & { rejection?: unknown } ): RejectedDraftSettlement { - return classifyDispatchRejection(rejection).verdict === null - ? { state: 'waiting' } - : { state: 'returned' } + const { verdict, kind } = classifyDispatchRejection(rejected) + if (verdict !== null) { + return { state: 'returned' } + } + const cutShort = kind === 'hostRestarted' || kind === 'chatClosed' + return { state: 'waiting', kept: cutShort && rejected.origin === 'client' } } /** True when committing this row NEWLY settles the submission to `rejected` — diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 8c190196bdf..902f565b617 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -35,6 +35,7 @@ import { } from './queued-message-table' import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-message-positions' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { queuedMessageSettlementOwed, @@ -72,6 +73,10 @@ export class JournalQueuedMessages { constructor(private readonly deps: JournalQueuedMessagesDeps) {} + get sessionId(): string { + return this.deps.sessionId + } + revision(): number { return this.changeRevision } @@ -102,8 +107,9 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. - * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused; + * `holdReason` carries a hold of its own over with it. `receipt`: the send's ledger answer, + * committed with the draft only when this inserts it. */ insert( input: { messageId: string @@ -112,6 +118,7 @@ export class JournalQueuedMessages { hostInstance: string carriedFrom?: string source: AgentSessionMessageSource + holdReason?: QueuedMessageHoldReason }, receipt?: JournalOperationReceipt ): Promise { @@ -193,6 +200,26 @@ export class JournalQueuedMessages { ).then((changed) => changed > 0) } + /** Inside the caller's journal-row transaction (`journal-unsent-send-hold.ts`): one kept send + * becomes a card, and the cards ahead of the queue take the positions given. False when a card + * by that id already exists, which then stands. */ + holdInTransaction( + db: Database.Database, + input: { + card: Omit[1], 'sessionId' | 'now'> | null + positions: readonly QueuedMessagePositionMove[] + } + ): boolean { + const { sessionId } = this.deps + this.changeRevision += moveQueuedMessages(db, sessionId, input.positions) + if (!input.card || getQueuedMessage(db, sessionId, input.card.messageId)) { + return false + } + insertQueuedMessage(db, { ...input.card, sessionId, now: this.deps.now() }) + this.changeRevision++ + return true + } + /** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones, * kept only so a replay of the settling operation answers "spent". */ withdraw(input: { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 36a7d7262e8..cba9c9a9bd5 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -8,6 +8,7 @@ import type { AgentSessionJournalIdentity, AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { @@ -112,6 +113,9 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...(input.state === 'rejected' && input.keptAsQueuedMessageId !== undefined + ? { keptAsQueuedMessageId: input.keptAsQueuedMessageId } + : {}), // Every rejection states its turn, null for none, so a reader tells it from an older row. ...(input.state === 'rejected' ? { @@ -321,6 +325,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + source?: Pick }): JournalSubmissionRow { return { kind: 'submission', @@ -331,6 +336,8 @@ export function buildJournalSubmissionRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), - ...(input.origin !== undefined ? { origin: input.origin } : {}) + ...(input.origin !== undefined ? { origin: input.origin } : {}), + // The kind only: a caller's full source carries senders that stay host-only. + ...(input.source !== undefined ? { source: { kind: input.source.kind } } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9d436e1eb54..d2a95a6db72 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -144,6 +144,9 @@ export type JournalSubmissionRow = JournalRowBase & { * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it * was recorded. Older readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin + /** Who it is from: its `AgentSessionMessageSource`'s kind only (`AgentJournalSubmission`). + * Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ + source?: { kind: string } } export type JournalSubmissionOrigin = 'client' | 'host' @@ -160,6 +163,9 @@ export type JournalDispatchRow = JournalRowBase & { /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact + /** On `rejected`: the card the host kept this send as (`AgentJournalSubmission`). Older readers + * keep the key and ignore it. */ + keptAsQueuedMessageId?: string /** On `rejected`: the turn a Codex send was answered into, and how it joined it, when that * turn's end settled the send; null on every other rejection. Absent on other rows and on rows * written before it. `via` stays a string: a newer build may write another. Older readers keep diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 1995b2e9875..b4dbe3735ba 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -19,6 +19,7 @@ import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { JournalStepWriter } from './journal-step-writer' import { restoreJournalStore } from './journal-store-restore' +import { JournalSubmissionWriter } from './journal-submission-writer' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import type { JournalWriteBody } from './journal-write-queue' @@ -53,6 +54,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + submissionWriter: JournalSubmissionWriter stepWriter: JournalStepWriter queuedMessages: JournalQueuedMessages stopMarks: JournalStopMarks @@ -119,6 +121,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuedMessages.repairAndPruneAtOpen() ), rowWriter, + submissionWriter: new JournalSubmissionWriter({ + state: host.state, + identity: host.identity, + rowWriter, + queuedMessages + }), itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c2547c658c8..2d7b56d06b1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -11,6 +11,7 @@ import type { AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' @@ -43,6 +44,7 @@ export type ResolveDispatchInput = { * `agentSessionFailureWords`, never written by hand. */ | ({ state: 'rejected' + keptAsQueuedMessageId?: string answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) | { state: 'unknown'; reason?: string | null } @@ -99,6 +101,8 @@ export type JournalSubmissionInput = { queuedMessageId?: string /** Who asked for this turn (`JournalSubmissionRow.origin`). */ origin?: 'client' | 'host' + /** Who it is from (`JournalSubmissionRow.source`); the row keeps the kind only. */ + source?: Pick } /** A submission append that converts a queued draft, in one transaction. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 626b77cf614..9b676cb26b3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -41,11 +41,7 @@ import { resolveJournalItemId, type JournalReducerState } from './journal-reducer' -import { - journalDispatchRowBuilder, - journalSubmissionRowBuilder, - journalTombstoneRowBuilder -} from './journal-row-builders' +import { journalTombstoneRowBuilder } from './journal-row-builders' import type { AgentSessionJournalOptions, JournalAppendResult, @@ -58,13 +54,18 @@ import type { JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' +import type { + JournalOperationReceipt, + JournalRowTransactionHook, + JournalRowWriter +} from './journal-row-writer' +import type { JournalSubmissionWriter } from './journal-submission-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' @@ -89,6 +90,7 @@ export class AgentSessionJournal { private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender + private readonly submissionWriter: JournalSubmissionWriter private readonly stepWriter: JournalStepWriter private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ @@ -132,6 +134,7 @@ export class AgentSessionJournal { this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.submissionWriter = collaborators.submissionWriter this.stepWriter = collaborators.stepWriter this.queuedMessages = collaborators.queuedMessages this.stopMarks = collaborators.stopMarks @@ -313,35 +316,21 @@ export class AgentSessionJournal { /** Several writes as one turn in the queue; see `JournalStepWriter`. */ appendSteps: JournalStepWriter['append'] = (steps) => this.stepWriter.append(steps) - /** - * Write-ahead submission row. It is durable before the caller dispatches - * anything, and it doubles as the optimistic user bubble so an accepted echo - * reconciles into an existing slot instead of appending a second copy. - */ + /** The write-ahead submission row (`JournalSubmissionWriter.append`). */ appendSubmission( input: JournalSubmissionInput, - /** Present: this submission is a queued draft's conversion, and the draft's - * state transition commits in the SAME transaction — exactly-once consume. */ consume?: JournalSubmissionConsume, - /** The send's ledger answer, committed with this row. */ receipt?: JournalOperationReceipt ): Promise { - return this.rowWriter.append( - journalSubmissionRowBuilder(() => this.state, this.identity, input, consume), - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume), - receipt - ) + return this.submissionWriter.append(input, consume, receipt) } - /** - * Record a dispatch transition, including a proven retry returning to pending. - * - * Accepting REQUIRES the provider identity rather than a free-form id: the - * adopted key is what the provider's echo will upsert into, so a mismatched - * string here would silently give the user a second copy of their own message. - */ - resolveDispatch(input: ResolveDispatchInput): Promise { - return this.rowWriter.append(journalDispatchRowBuilder(() => this.state, input)) + /** A dispatch transition (`JournalSubmissionWriter.resolveDispatch`). */ + resolveDispatch( + input: ResolveDispatchInput, + hook?: JournalRowTransactionHook + ): Promise { + return this.submissionWriter.resolveDispatch(input, hook) } /** Retire unanswered sends after their execution owner ended, without assuming delivery. */ diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index b00bdd36de2..b93ad8c6fcf 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -31,7 +31,10 @@ export function applyJournalSubmission( ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 ? { queuedMessageId: row.queuedMessageId } : {}), - ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) + ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), + // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither + // reads as a row without one. + ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. @@ -134,3 +137,13 @@ export function notePersonTurnAccepted( ) } } + +/** A stored source's kind; an undecodable value reads as an empty kind, never a person's. */ +function storedSourceKind(stored: unknown): string { + return typeof stored === 'object' && + stored !== null && + 'kind' in stored && + typeof stored.kind === 'string' + ? stored.kind + : '' +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index d0306fa9467..dda3cd71695 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -6,7 +6,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-schemas' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' import type { AgentJournalMessageItem, AgentSessionJournalIdentity diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts new file mode 100644 index 00000000000..c79fa603031 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -0,0 +1,67 @@ +// A sent message's rows: its write-ahead submission and each dispatch transition, with what must +// commit in the same transaction (a queued draft's consume, the send's ledger answer, a kept card). + +import type { + AgentJournalCursor, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalReducerState } from './journal-reducer' +import { journalDispatchRowBuilder, journalSubmissionRowBuilder } from './journal-row-builders' +import type { + JournalOperationReceipt, + JournalRowTransactionHook, + JournalRowWriter +} from './journal-row-writer' +import type { + JournalSubmissionConsume, + JournalSubmissionInput, + ResolveDispatchInput +} from './journal-store-contracts' + +export type JournalSubmissionWriterDeps = { + state: () => JournalReducerState + identity: AgentSessionJournalIdentity + rowWriter: JournalRowWriter + queuedMessages: JournalQueuedMessages +} + +export class JournalSubmissionWriter { + constructor(private readonly deps: JournalSubmissionWriterDeps) {} + + /** + * Write-ahead submission row. It is durable before the caller dispatches + * anything, and it doubles as the optimistic user bubble so an accepted echo + * reconciles into an existing slot instead of appending a second copy. + */ + append( + input: JournalSubmissionInput, + /** Present: this submission is a queued draft's conversion, and the draft's + * state transition commits in the SAME transaction — exactly-once consume. */ + consume?: JournalSubmissionConsume, + /** The send's ledger answer, committed with this row. */ + receipt?: JournalOperationReceipt + ): Promise { + const { identity, queuedMessages, rowWriter, state } = this.deps + return rowWriter.append( + journalSubmissionRowBuilder(state, identity, input, consume), + consume && queuedMessageConsumeHook(queuedMessages, input.clientMessageId, consume), + receipt + ) + } + + /** + * Record a dispatch transition, including a proven retry returning to pending. + * + * Accepting REQUIRES the provider identity rather than a free-form id: the + * adopted key is what the provider's echo will upsert into, so a mismatched + * string here would silently give the user a second copy of their own message. + * `hook` runs in the row's transaction: it commits with the row, or rolls it back by throwing. + */ + resolveDispatch( + input: ResolveDispatchInput, + hook?: JournalRowTransactionHook + ): Promise { + return this.deps.rowWriter.append(journalDispatchRowBuilder(this.deps.state, input), hook) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts new file mode 100644 index 00000000000..f559f07db12 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -0,0 +1,486 @@ +// Which unsent sends a restart or a close keeps as held cards, where they go in the queue, and that +// a send's source is recorded and folded. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + USER_MESSAGE_SOURCE, + type AgentMessageSource, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' +import { + holdUnsentSends, + unsentSendKeptAsCard, + type UnsentSendHold +} from './journal-unsent-send-hold' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-held', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) +} +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) +const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' +}) +/** The live host process the cards are written for. */ +const HOST = 'host-instance-2' + +let root: string +let clock = 1_000 +let epochs = 0 +const journals = createTrackedJournalOpener() + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function fingerprint(body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: IDENTITY.sessionId, + fields: { body } + }) +} + +async function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${(epochs += 1)}` + }) +} + +function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'hostRestarted' }) { + return holdUnsentSends(journal, { fence: 0, hostInstance: HOST, hold: settle }) +} + +/** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ +const MAIL_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [{ party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null } }], + orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } +} + +async function accept( + journal: AgentSessionJournal, + id: string, + fields: { + body?: AgentJournalMessageItem + origin?: 'client' | 'host' + source?: AgentSessionMessageSource + } = {} +): Promise { + const body = fields.body ?? message(`text of ${id}`) + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: fingerprint(body), + body, + fence: 0, + handoverRecorded: true, + ...(fields.origin ? { origin: fields.origin } : {}), + ...(fields.source ? { source: fields.source } : {}) + }) +} + +/** Writes rows as a process that then quit, and opens the journal again as the next one. */ +async function afterRestart( + write: (journal: AgentSessionJournal) => Promise +): Promise { + const earlier = await open() + await write(earlier) + await earlier.close() + return open() +} + +/** Rewrites a stored submission's `source` as another build would have written it. */ +function storeSubmissionSourceAs(journal: AgentSessionJournal, id: string, source: unknown): void { + const seq = journal.submission(id)?.submittedSequence + const { db } = openTestJournalHostDatabase(root) + const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === seq) + if (!stored) { + throw new Error(`no stored row for ${id}`) + } + const row: unknown = JSON.parse(stored.rowJson) + updateTestJournalRowJson( + db, + IDENTITY.sessionId, + stored.seq, + JSON.stringify({ ...(typeof row === 'object' ? row : {}), source }) + ) +} + +function cardOrder(journal: AgentSessionJournal): string[] { + return journal.queuedMessages + .list() + .filter((card) => card.state === 'waiting') + .map((card) => card.messageId) +} + +beforeEach(async () => { + epochs = 0 + root = await mkdtemp(join(tmpdir(), 'orca-unsent-hold-')) +}) + +afterEach(async () => { + await journals.closeAll() + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +describe('which sends an earlier host process left unsent are kept', () => { + it('keeps a person’s and a launch’s text, and an older build’s client send', async () => { + const journal = await afterRestart(async (earlier) => { + await accept(earlier, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'launch', { origin: 'host', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'legacy-client', { origin: 'client' }) + }) + await hold(journal) + + expect(cardOrder(journal)).toEqual(['person', 'launch', 'legacy-client']) + await journal.close() + // The rejection names the card it was kept as, read back from disk. + const reopened = await open() + for (const id of ['person', 'launch', 'legacy-client']) { + expect(reopened.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + keptAsQueuedMessageId: id + }) + expect(reopened.queuedMessages.get(id)).toMatchObject({ + state: 'waiting', + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + hostInstance: HOST, + body: message(`text of ${id}`), + fingerprint: fingerprint(message(`text of ${id}`)), + carriedFrom: null, + queuedAt: { epoch: 'epoch-1', sequence: reopened.submission(id)?.acceptedSequence } + }) + } + }) + + it('rejects mail, a dispatch preamble, a continuation, /compact, an image, and a send no one can attribute', async () => { + const image: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'image-ref', path: '/tmp/attachment.png' } + ] + } + const journal = await afterRestart(async (earlier) => { + await accept(earlier, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await accept(earlier, 'dispatch', { origin: 'host' }) + await accept(earlier, 'continuation', { origin: 'host' }) + await accept(earlier, 'compact', { + origin: 'client', + source: USER_MESSAGE_SOURCE, + body: structuredAgentSessionCompactBody() + }) + await accept(earlier, 'image', { origin: 'client', source: USER_MESSAGE_SOURCE, body: image }) + await accept(earlier, 'legacy-host', { origin: 'host' }) + await accept(earlier, 'no-origin') + }) + await hold(journal) + + expect(journal.queuedMessages.list()).toEqual([]) + for (const id of [ + 'mail', + 'dispatch', + 'continuation', + 'compact', + 'image', + 'legacy-host', + 'no-origin' + ]) { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(journal.submission(id)).not.toHaveProperty('keptAsQueuedMessageId') + } + }) + + // Only a Send the person asked for comes back kept; the queue's own hand-off waits under the + // restart's pause, where it stood. + it.each([ + { by: 'Send now', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, + { by: 'the queue', origin: 'host' as const, holdReason: null } + ])( + 'a card’s own hand-off by $by returns its card, and makes no second one', + async ({ origin, holdReason }) => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'card', + body: message('card text'), + fingerprint: fingerprint(message('card text')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await earlier.appendSubmission( + { + clientMessageId: 'handoff', + payloadFingerprint: fingerprint(message('card text')), + body: message('card text'), + fence: 0, + handoverRecorded: true, + origin + }, + { messageId: 'card', expect: 'waiting', settledByOp: null } + ) + }) + await hold(journal) + + expect(journal.queuedMessages.list()).toHaveLength(1) + expect(journal.queuedMessages.get('card')).toMatchObject({ + state: 'waiting', + consumedAs: null, + holdReason + }) + expect(journal.queuedMessages.get('handoff')).toBeNull() + } + ) + + it('leaves alone what this process accepted', async () => { + const journal = await open() + await accept(journal, 'mine', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await hold(journal) + expect(journal.submission('mine')?.dispatchState).toBe('pending') + expect(journal.queuedMessages.list()).toEqual([]) + }) + + it('never keeps a queue hand-off, a card’s link, or a send with no message body', () => { + const body = message('x') + expect(unsentSendKeptAsCard({ source: USER_MESSAGE_SOURCE }, body)).toBe(body) + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'agent' } }, body)).toBeNull() + // Undecodable (folded as an empty kind) and unknown kinds are never a person's. + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: '' } }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'orca' } }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client', queuedMessageId: 'card' }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client' }, null)).toBeNull() + }) + + // Only a row with no source at all is an older build's; a newer build's kind may name a sender + // that must not become a card. + it('never keeps a source kind this build does not know, whatever its origin', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.appendSubmission({ + clientMessageId: 'future', + payloadFingerprint: fingerprint(message('from a newer build')), + body: message('from a newer build'), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) + storeSubmissionSourceAs(earlier, 'future', { kind: 'a-newer-kind' }) + }) + expect(journal.submission('future')?.source).toEqual({ kind: 'a-newer-kind' }) + await hold(journal) + expect(journal.queuedMessages.list()).toEqual([]) + expect(journal.submission('future')).toMatchObject({ dispatchState: 'rejected' }) + }) + + // A source with no readable kind is not a row without one: an older build's rule never applies. + it('never keeps a send whose stored source has no readable kind', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.appendSubmission({ + clientMessageId: 'unreadable', + payloadFingerprint: fingerprint(message('unreadable')), + body: message('unreadable'), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) + storeSubmissionSourceAs(earlier, 'unreadable', {}) + }) + expect(journal.submission('unreadable')?.source).toEqual({ kind: '' }) + await hold(journal) + expect(journal.queuedMessages.list()).toEqual([]) + expect(journal.submission('unreadable')).toMatchObject({ dispatchState: 'rejected' }) + }) +}) + +describe('where kept sends go in the queue', () => { + // A Send the person asked for comes back kept among them; the queue's own hand-off stays put. + it.each([ + { by: 'Send now', origin: 'client' as const, order: ['A', 'H', 'B', 'C'] }, + { by: 'the queue', origin: 'host' as const, order: ['A', 'B', 'H', 'C'] } + ])( + 'in acceptance order, ahead of the cards already waiting, a hand-off by $by among them', + async ({ origin, order }) => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'H', + body: message('H'), + fingerprint: fingerprint(message('H')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await earlier.appendSubmission( + { + clientMessageId: 'H-handoff', + payloadFingerprint: fingerprint(message('H')), + body: message('H'), + fence: 0, + handoverRecorded: true, + origin + }, + { messageId: 'H', expect: 'waiting', settledByOp: null } + ) + await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + await hold(journal) + + expect(cardOrder(journal)).toEqual(order) + } + ) + + it('a run a crash cut short is placed again with the rest, in acceptance order', async () => { + const interrupted = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + // That run kept A, at a stale place behind C, then died before B. + await interrupted.resolveDispatch( + { clientMessageId: 'A', state: 'rejected', ...HOST_RESTARTED, fence: 0, recovered: true }, + (db) => { + interrupted.queuedMessages.holdInTransaction(db, { + card: { + messageId: 'A', + body: message('text of A'), + fingerprint: fingerprint(message('text of A')), + hostInstance: HOST, + source: USER_MESSAGE_SOURCE, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + queuedAt: { + epoch: 'epoch-1', + sequence: interrupted.submission('A')!.acceptedSequence! + }, + position: 5 + }, + positions: [] + }) + } + ) + await interrupted.close() + const journal = await open() + await hold(journal) + + expect(cardOrder(journal)).toEqual(['A', 'B', 'C']) + }) +}) + +describe('a close of the chat', () => { + it('keeps a person’s unsent send in place, rejected as closed, by the same rule as a restart', async () => { + const journal = await open() + await accept(journal, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await hold(journal, { cause: 'chatClosed' }) + + expect(cardOrder(journal)).toEqual(['person']) + expect(journal.queuedMessages.get('person')).toMatchObject({ + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + hostInstance: HOST + }) + for (const id of ['person', 'mail']) { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) + } + }) + + it('settles only what `which` names, leaving a later send queued', async () => { + const journal = await open() + await accept(journal, 'before', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'after', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await hold(journal, { + cause: 'chatClosed', + which: (submission) => submission.clientMessageId === 'before' + }) + + expect(cardOrder(journal)).toEqual(['before']) + expect(journal.submission('after')?.dispatchState).toBe('pending') + }) +}) + +describe('the order kept cards stand in', () => { + // Sequences restart with each epoch, so a card kept before a rewind has no sequence to compare. + it('a card kept before the epoch rolled stays ahead of one kept after it', async () => { + const journal = await afterRestart(async (earlier) => { + for (const id of ['m1', 'm2', 'm3']) { + await accept(earlier, id, { origin: 'host', source: MAIL_SOURCE }) + } + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + await hold(journal) + await journal.rollEpoch('handle_forked', 0) + await accept(journal, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await journal.close() + const reopened = await open() + await hold(reopened) + + expect(reopened.queuedMessages.get('A')?.queuedAt?.epoch).not.toBe( + reopened.queuedMessages.get('B')?.queuedAt?.epoch + ) + expect(cardOrder(reopened)).toEqual(['A', 'B']) + }) +}) + +describe('the source a send records', () => { + it('is folded from the row; a row without one folds without it', async () => { + const journal = await open() + await accept(journal, 'with-source', { origin: 'host', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'without', { origin: 'client' }) + const folded: AgentJournalSubmission | undefined = journal.submission('with-source') + expect(folded?.source).toEqual({ kind: 'user' }) + expect(journal.submission('without')).not.toHaveProperty('source') + }) + + // Who sent it stays on the card, host-only; a submission is published to clients as it folds. + it('keeps only the kind of an agent’s source, never its senders', async () => { + const journal = await open() + await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await journal.close() + const reopened = await open() + expect(reopened.submission('mail')?.source).toEqual({ kind: 'agent' }) + expect(JSON.stringify(reopened.submission('mail'))).not.toContain('agent:coordinator') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts new file mode 100644 index 00000000000..f08d8bb05c4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -0,0 +1,208 @@ +// What becomes of a send the host accepted and can no longer hand over: an earlier host process +// quit or crashed first (settled at the next open), or the chat is closing. Either way the agent +// provably never got it. A person's message (typed, or a launch's first prompt) is kept as a card +// at the head of the queue, held until the person sends, edits or deletes it (`kept`). Everything +// else is rejected as before, because something else re-derives it or the person re-runs it. The +// submission itself is always rejected, so it is never handed over twice. + +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { AgentSessionJournal } from './journal-store' +import type { JournalRowTransactionHook } from './journal-row-writer' +import type { QueuedMessagePositionMove } from './queued-message-positions' + +/** Why the host can no longer hand a send over, which also says which sends it is. */ +export type UnsentSendHold = + /** At open, and the delivery loop's first step: what an earlier host process accepted. */ + | { cause: 'hostRestarted' } + /** A close of the chat; `which` narrows it to what a close that did not complete closed. */ + | { cause: 'chatClosed'; which?: (submission: AgentJournalSubmission) => boolean } + +/** The body an unsent send is kept with, or null when it is rejected instead: + * - a card's own hand-off: its rejection already returns the card (`rejectedDraftSettlement`); + * - `/compact` and other commands: a command in flight is not resumed, the person re-runs it; + * - an image: cards are text-only; + * - a send not from a person: orchestration mail (the mailbox re-sends it), a dispatch preamble + * or a restart continuation (their owners re-derive them), a kind this build does not know, and + * a row with no source that is not a person's (it could be either). */ +export function unsentSendKeptAsCard( + submission: Pick, + body: AgentJournalItemBody | null +): AgentJournalMessageItem | null { + if (submission.queuedMessageId !== undefined || body?.kind !== 'message' || body.command) { + return null + } + if (!body.blocks.every((block) => block.type === 'text')) { + return null + } + const { source } = submission + // Exactly 'user': never `readAgentSessionMessageSource`, whose fallback for what it cannot read + // is the person. + const persons = + source?.kind === USER_MESSAGE_SOURCE.kind || + // A build before `source` was recorded: `client` was only ever a person's send. + (source === undefined && submission.origin === 'client') + return persons ? body : null +} + +/** + * Settles every send `hold` names. Each is rejected with the hold's cause in its own row, and a + * kept one becomes a card in that row's transaction, so a crash between them can never leave + * both. This batch's cards, and the card of a Send the person asked for, go to the head of the + * queue in the order they were accepted, behind the cards an earlier settlement kept; the queue's + * own hand-off returns its card where it stood. Throws once every row was + * tried when one of them could not be written at all: that row stays queued. + */ +export async function holdUnsentSends( + journal: AgentSessionJournal, + input: { fence: number; hostInstance: string; hold: UnsentSendHold } +): Promise { + const { hold } = input + const unsent = journal + .submissions() + .filter( + (entry) => + isQueuedAgentJournalSubmission(entry) && + (hold.cause === 'hostRestarted' + ? journal.wroteBeforeOpen(entry.acceptedSequence) + : (hold.which?.(entry) ?? true)) + ) + .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) + if (unsent.length === 0) { + return + } + const { epoch } = journal.cursor() + const kept = unsent.map((submission) => ({ + submission, + body: unsentSendKeptAsCard( + submission, + journal.itemBody(agentJournalSubmissionKey(submission.clientMessageId)) + ) + })) + const positions = headOfQueuePositions(journal, kept) + const rejection = agentSessionFailureWords(agentSessionFailureFact(hold.cause), { + surface: 'rejection' + }) + const failures: unknown[] = [] + for (const [index, { submission, body }] of kept.entries()) { + const { clientMessageId } = submission + const moves = [ + // The cards an earlier settlement kept move with the first row. + ...(index === 0 ? positions.earlier : []), + ...(submission.queuedMessageId !== undefined + ? positions.placed.filter( + (entry) => 'consumedAs' in entry && entry.consumedAs === clientMessageId + ) + : []) + ] + const position = positions.placed.find( + (entry) => 'messageId' in entry && entry.messageId === clientMessageId + )?.position + const card = body && position !== undefined ? { body, position } : null + const keep: JournalRowTransactionHook | undefined = + card || moves.length > 0 + ? (db) => { + journal.queuedMessages.holdInTransaction(db, { + card: card + ? { + messageId: clientMessageId, + body: card.body, + fingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: journal.queuedMessages.sessionId, + fields: { body: card.body } + }), + hostInstance: input.hostInstance, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + // Only a person's send is kept. + source: USER_MESSAGE_SOURCE, + queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, + position: card.position + } + : null, + positions: moves + }) + } + : undefined + const reject = { + clientMessageId, + state: 'rejected' as const, + ...rejection, + fence: input.fence, + recovered: true as const + } + try { + // The send names its card in the same row, so no surface draws it once the card is gone. + await journal.resolveDispatch( + card ? { ...reject, keptAsQueuedMessageId: clientMessageId } : reject, + keep + ) + } catch (error) { + if (!keep) { + failures.push(error) + continue + } + // Keeping it failed: rejected as before. That loses the message, as every build before this + // one did; it is never left queued for a handover nothing will make. + console.warn('[journal-hold] keeping an unsent send failed:', { + sessionId: journal.queuedMessages.sessionId, + clientMessageId, + cause: hold.cause, + error: error instanceof Error ? error.message : String(error) + }) + await journal.resolveDispatch(reject).catch((fallback: unknown) => failures.push(fallback)) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'settling unsent sends failed') + } +} + +/** Where each card of the batch goes: right before every other card, the cards an earlier + * settlement kept first, in the order they stand, then this batch's cards and the cards of the + * person's own Sends in the order they were accepted. A kept card's `queuedAt` cannot order it against + * them: sequences restart with each epoch, and a returned hand-off's names its draft's time. */ +function headOfQueuePositions( + journal: AgentSessionJournal, + batch: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] +): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { + const cards = journal.queuedMessages.list() + const earlier = cards + .filter((card) => card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) + .map((card) => card.messageId) + const placed: QueuedMessagePositionMove[] = [] + for (const { submission, body } of batch) { + const { clientMessageId } = submission + if (body && !earlier.includes(clientMessageId)) { + placed.push({ messageId: clientMessageId, position: 0 }) + } else if ( + // Kept by its settlement (`rejectedDraftSettlement`): a Send the person asked for. + submission.origin === 'client' && + cards.some((card) => card.consumedAs === clientMessageId) + ) { + placed.push({ consumedAs: clientMessageId, position: 0 }) + } + } + const inHead = (card: (typeof cards)[number]): boolean => + earlier.includes(card.messageId) || + placed.some((move) => + 'messageId' in move ? move.messageId === card.messageId : move.consumedAs === card.consumedAs + ) + const others = cards.filter((card) => !inHead(card)).map((card) => card.position) + const anchor = others.length > 0 ? Math.min(...others) : 1 + const first = anchor - earlier.length - placed.length + return { + earlier: earlier.map((messageId, index) => ({ messageId, position: first + index })), + placed: placed.map((move, index) => ({ ...move, position: first + earlier.length + index })) + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts index 00b90a832c1..05867436e12 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,6 +1,7 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A // Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); -// a per-draft hold is only a conversion that failed, which an explicit Send releases. +// a per-draft hold is a conversion that failed, or a send the host kept +// (`QueuedMessageHoldReason`), which an explicit Send releases. import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5989a10e967..f9bf0fb2d0b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -29,6 +29,7 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { @@ -520,6 +521,24 @@ describe("a restart's pause", () => { ]) }) + it('adoption and Resume keep a kept card held: only its own Send, Edit or Delete releases it', async () => { + const journal = await open() + const kept = await journal.queuedMessages.insert({ + messageId: 'kept', + body: message('kept across a restart'), + fingerprint: 'fp-kept', + hostInstance: 'proc-0', + source: { kind: 'user' }, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + expect(await journal.queuedMessages.adopt(HOST)).toBe(true) + expect(journal.queuedMessages.get('kept')).toMatchObject({ + hostInstance: HOST, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + }) + it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { const journal = await open() await queueDraft(journal, 'draft-1') @@ -596,6 +615,31 @@ describe('which cards the pauses in force hold', () => { expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') }) + // Held on its own, as a card whose send failed: the queue goes past it, and Resume is offered + // over the cards a pause holds behind it. + it('a kept card is skipped like a send_failed one; the cards behind it still send', () => { + const behind = card('behind', 2) + const kept = [card('kept', 1, { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] + expect(holding(kept, 0)).toEqual([ + ['kept', null], + ['behind', null] + ]) + expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) + expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() + const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] + expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() + expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') + }) + + // A dead process's card held on its own waits for its own Send, so it pauses no other card. + it('a card held on its own from a dead process starts no restart pause', () => { + for (const holdReason of [QUEUED_MESSAGE_PAUSED_KEPT, 'send_failed']) { + const cards = [card('held', 1, { hostInstance: DEAD, holdReason }), card('live', 2)] + expect(pausesOver(cards, 0)).toEqual([]) + expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('live') + } + }) + it("a /clear's pause that holds nothing never hides a restart's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 63dc6823df2..de3b702067c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -5,8 +5,9 @@ // supersedes it; only a person's pauses. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or // Resume has happened here since. -// - 'restarted': a waiting card was written by another host process, and no person's turn has -// started since this conversation opened. +// - 'restarted': a waiting card with no hold of its own was written by another host process, and +// no person's turn has started since this conversation opened. +// A card held on its own (`hold_reason`) is outside every pause: only an action on it releases it. // A person's turn is an accepted submission of origin `client`. Orchestration mail, a restart // continuation, a launch prompt and the queue's own drain are `host` and never lift it. @@ -128,7 +129,11 @@ export function deriveQueuePauses(input: { if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { pauses.push({ reason: 'cleared', since: null }) } - if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { + // A card held on its own waits for its own Send whoever wrote it, so it pauses nothing else. + const foreign = waiting.some( + (card) => card.holdReason === null && card.hostInstance !== input.hostInstance + ) + if (!input.restartEnded && foreign) { // The process that wrote a card is gone: every card waits, whenever it was written. pauses.push({ reason: 'restarted', since: null }) } @@ -152,8 +157,8 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { } // Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards -// queued before it, and a steer it withdrew, wait. It still never jumps ahead of a held card: the -// drain stops at the first one. true instead holds every waiting card, whenever it was queued. +// queued before it, and a steer it withdrew, wait. It still never jumps ahead of one a pause holds: +// the drain stops at the first one. true instead holds every waiting card, whenever it was queued. const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false /** THE rule for which cards are held: by ANY pause in force, named by the first that holds it, so @@ -170,8 +175,9 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or a held one comes first. The queue never reorders, so a newer card never - * overtakes a held one. The drain's pick and its consume both read this. */ + * returned card or one a pause holds comes first. The queue never reorders, so a newer card never + * overtakes one a pause holds; a card held on its own is passed over. The drain's pick and its + * consume both read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] diff --git a/src/main/native-chat/agent-session-journal/queued-message-positions.ts b/src/main/native-chat/agent-session-journal/queued-message-positions.ts new file mode 100644 index 00000000000..9916e6fc31b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-positions.ts @@ -0,0 +1,31 @@ +// Where a card sits in its queue, for a writer placing cards ahead of the others +// (`journal-unsent-send-hold.ts`). The queue sends in position order. + +import type Database from '../../sqlite/sync-database' + +/** A card to move, named by its id or, for one handed off, by the submission that consumed it. */ +export type QueuedMessagePositionMove = + | { messageId: string; position: number } + | { consumedAs: string; position: number } + +/** Returns how many rows moved. */ +export function moveQueuedMessages( + db: Database.Database, + sessionId: string, + moves: readonly QueuedMessagePositionMove[] +): number { + let moved = 0 + for (const move of moves) { + const [column, key] = + 'messageId' in move ? ['message_id', move.messageId] : ['consumed_as', move.consumedAs] + moved += Number( + db + .prepare( + `UPDATE queued_messages SET position = ? + WHERE session_id = ? AND ${column} = ? AND position <> ?` + ) + .run(move.position, sessionId, key, move.position).changes ?? 0 + ) + } + return moved +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-retention.ts b/src/main/native-chat/agent-session-journal/queued-message-retention.ts index cf18a1e5006..46717268ad1 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-retention.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-retention.ts @@ -7,7 +7,7 @@ import { listQueuedMessages } from './queued-message-table' /** What the loaded journal says about a dispatched draft's consumed submission. */ export type QueuedMessageSubmissionVerdict = - /** Still owed an answer — a crash leftover the delivery loop will reject; keep the row. */ + /** Still owed an answer — a crash leftover the next open settles; keep the row. */ | 'pending' /** `accepted` or `unknown`: terminal and not refused. */ | 'terminal-not-refused' diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 8d2e9ec0acc..029bdb74e6f 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -58,6 +58,7 @@ export function settleOwedQueuedMessages( consumedRef, reason: submission?.reason ?? null, rejection: submission?.rejection, + origin: submission?.origin, now: input.now }) settled += changed ? 1 : 0 @@ -81,7 +82,8 @@ export function settleOwedQueuedMessages( * The live hook, before `row` applies: an echo proving a waiting draft's first * send was delivered withdraws it; a row that NEWLY settles a dispatched * draft's current submission to `rejected` settles the draft — a refusal - * returns it, a withdrawal (a Stop, a restart) sends it back to waiting. + * returns it, a withdrawal (a Stop, a restart) sends it back to waiting + * (`rejectedDraftSettlement`). * Decided by the same function the reducer folds rows through, so a row the * journal's settlement rules ignore never alters a draft. Returns how many * drafts changed. @@ -113,7 +115,8 @@ export function settleQueuedMessagesForRow( if (row.kind !== 'dispatch' || row.state !== 'rejected') { return changed } - if (!journalDispatchRowNewlyRejects(input.state.submissions.get(row.clientMessageId), row)) { + const submission = input.state.submissions.get(row.clientMessageId) + if (!journalDispatchRowNewlyRejects(submission, row)) { return changed } const settled = settleRejectedQueuedMessage(db, { @@ -121,6 +124,7 @@ export function settleQueuedMessagesForRow( consumedRef: row.clientMessageId, reason: row.reason, rejection: row.rejection, + origin: submission?.origin, now: input.now }) return changed + (settled ? 1 : 0) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index a9c0ef7c70c..264d17b572a 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -13,6 +13,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' @@ -90,7 +91,12 @@ async function queueDraft(journal: AgentSessionJournal, messageId: string, text async function consumeDraft( journal: AgentSessionJournal, messageId: string, - options: { as?: string; expect?: 'waiting' | 'returned'; settledByOp?: string | null } = {} + options: { + as?: string + expect?: 'waiting' | 'returned' + settledByOp?: string | null + origin?: 'client' | 'host' + } = {} ) { const draft = journal.queuedMessages.get(messageId) await journal.appendSubmission( @@ -99,7 +105,8 @@ async function consumeDraft( payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, body: draft?.body ?? message('queued text'), fence: 0, - handoverRecorded: true + handoverRecorded: true, + ...(options.origin ? { origin: options.origin } : {}) }, { messageId, @@ -400,25 +407,32 @@ describe('returned transition (D1/N4)', () => { expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') }) - it("a restart between consume and handover sends the draft back to waiting under the restart's pause", async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => - journal.wroteBeforeOpen(submission.acceptedSequence) - ) - // No stored hold: the restart's pause derives from the row's host instance. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - hostInstance: 'proc-1', - consumedAs: null, - returnedReason: null - }) - }) + it.each([ + { by: 'the queue', origin: 'host' as const, holdReason: null }, + { by: 'the person', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + ])( + 'a restart between $by’s consume and handover sends the draft back to waiting', + async ({ origin, holdReason }) => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1', { origin }) + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + journal.wroteBeforeOpen(submission.acceptedSequence) + ) + // The queue's own hand-off waits under the restart's pause, derived from the row's host + // instance; a Send the person asked for waits for them, kept. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason, + hostInstance: 'proc-1', + consumedAs: null, + returnedReason: null + }) + } + ) it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { const journal = await open() @@ -569,6 +583,46 @@ describe('open-time repair and retention', () => { } }) + // The repair reaches the live hook's answer from the stored rejection and who asked for it. + it.each([ + { + origin: 'client' as const, + cause: 'hostRestarted' as const, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }, + { origin: 'host' as const, cause: 'hostRestarted' as const, holdReason: null }, + { + origin: 'client' as const, + cause: 'chatClosed' as const, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }, + { origin: 'host' as const, cause: 'chatClosed' as const, holdReason: null } + ])( + 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, holdReason $holdReason', + async ({ origin, cause, holdReason }) => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1', { origin }) + await journal.rejectQueuedSubmissions( + 0, + agentSessionFailureWords(agentSessionFailureFact(cause), { surface: 'rejection' }) + ) + await journal.close() + // The hook "was skipped": the draft is back to dispatched behind the stored rejection. + const db = new Database(journalDatabasePath(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" + ).run('draft-1') + db.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason, + consumedAs: null + }) + } + ) + it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { let journal = await open() await queueDraft(journal, 'draft-1') diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts new file mode 100644 index 00000000000..f5a10c42073 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -0,0 +1,98 @@ +// Reads a `queued_messages` row back (`queued-message-table.ts`): a row this build cannot +// re-materialize is dropped, never shown as an empty message. + +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + readAgentSessionMessageSource, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' +import { readStoredRejectionFact } from './journal-dispatch-reducer' +import type { QueuedMessageRow } from './queued-message-table' + +/** A `queued_messages` row as this file's SELECTs return it; null when it cannot be read back. */ +export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. + const record = row as { + session_id: string + message_id: string + position: number + body_json: string + fingerprint: string + created_at: number + host_instance: string + state: string + hold_reason: string | null + returned_reason: string | null + returned_rejection: string | null + settled_at: number | null + settled_by_op: string | null + consumed_as: string | null + carried_from: string | null + queued_epoch: string | null + queued_sequence: number | null + source_json: string | null + } + let body: AgentJournalMessageItem + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. + body = JSON.parse(record.body_json) as AgentJournalMessageItem + } catch { + // Our own writer stringified it; an unreadable body is corruption, and a + // row we cannot re-materialize must not masquerade as an empty message. + return null + } + const state = record.state + if ( + state !== 'waiting' && + state !== 'dispatched' && + state !== 'returned' && + state !== 'withdrawn' + ) { + return null + } + return { + sessionId: record.session_id, + messageId: record.message_id, + position: record.position, + body, + fingerprint: record.fingerprint, + createdAt: record.created_at, + hostInstance: record.host_instance, + state, + holdReason: record.hold_reason, + returnedReason: record.returned_reason, + returnedRejection: storedRejection(record.returned_rejection), + settledAt: record.settled_at, + settledByOp: record.settled_by_op, + consumedAs: record.consumed_as, + carriedFrom: record.carried_from, + queuedAt: + record.queued_epoch !== null && typeof record.queued_sequence === 'number' + ? { epoch: record.queued_epoch, sequence: record.queued_sequence } + : null, + source: storedSource(record.source_json) + } +} + +function storedSource(json: string | null): AgentSessionMessageSource { + let stored: unknown = null + try { + stored = json === null ? null : JSON.parse(json) + } catch { + // An unreadable value is read as no value; the source reader decides what that means. + } + return readAgentSessionMessageSource(stored) +} + +function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { + if (json === null) { + return null + } + try { + return readStoredRejectionFact(JSON.parse(json)) ?? null + } catch { + // The refusal stays readable from `returned_reason`; a bad fact must not lose the card. + return null + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 1a82925f1f9..d68341888a6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -14,20 +14,26 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { - readAgentSessionMessageSource, + QUEUED_MESSAGE_PAUSED_KEPT, + type QUEUED_MESSAGE_PAUSED_SEND_FAILED +} from '../../../shared/agent-session-queued-message-wire' +import { serializeAgentSessionMessageSource, type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -import { readStoredRejectionFact } from './journal-dispatch-reducer' +import { readStoredQueuedMessageRow } from './queued-message-stored-row' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' -/** Why ONE waiting draft is held from auto-sending: its conversion failed. - * Stored on the row, so it survives handle eviction and restart; a wire marker - * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue - * instead. A reader treats an unknown stored value as a plain hold. */ -export type QueuedMessageHoldReason = 'send_failed' +/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`), or it + * is a send the host accepted and kept across a restart or a close (`kept`). Stored on the row, so + * it survives handle eviction and restart; a wire marker (it publishes as `pausedReason`). A Stop + * or a restart pauses the whole queue instead. A reader treats an unknown stored value as a plain + * hold. */ +export type QueuedMessageHoldReason = + | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED + | typeof QUEUED_MESSAGE_PAUSED_KEPT /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and * what the published list shows. Pending/unknown/accepted deliveries and @@ -83,16 +89,19 @@ export function insertQueuedMessage( queuedAt: AgentJournalCursor source: AgentSessionMessageSource now: number + /** Absent: after every other card. */ + position?: number + holdReason?: QueuedMessageHoldReason } ): QueuedMessageRow { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. const highest = db .prepare('SELECT COALESCE(MAX(position), 0) AS p FROM queued_messages WHERE session_id = ?') .get(input.sessionId) as { p?: number } | undefined - const position = Number(highest?.p ?? 0) + 1 + const position = input.position ?? Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -101,6 +110,7 @@ export function insertQueuedMessage( input.fingerprint, input.now, input.hostInstance, + input.holdReason ?? null, input.carriedFrom ?? null, input.queuedAt.epoch, input.queuedAt.sequence, @@ -115,7 +125,7 @@ export function insertQueuedMessage( createdAt: input.now, hostInstance: input.hostInstance, state: 'waiting', - holdReason: null, + holdReason: input.holdReason ?? null, returnedReason: null, returnedRejection: null, settledAt: null, @@ -131,7 +141,7 @@ export function listQueuedMessages(db: Database.Database, sessionId: string): Qu return db .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC`) .all(sessionId) - .flatMap((row) => toStoredRow(row) ?? []) + .flatMap((row) => readStoredQueuedMessageRow(row) ?? []) } export function getQueuedMessage( @@ -142,7 +152,7 @@ export function getQueuedMessage( const row = db .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?`) .get(sessionId, messageId) - return row === undefined ? null : toStoredRow(row) + return row === undefined ? null : readStoredQueuedMessageRow(row) } /** @@ -230,8 +240,8 @@ export function withdrawQueuedMessages( * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused * again still settles while a late duplicate of an earlier refusal matches - * nothing. A draft back to waiting keeps its position and carries no refusal; - * its spent submissions stay findable by their `queuedMessageId` link. + * nothing. A draft back to waiting keeps its position and carries no refusal, held as `kept` + * when the settlement says so; its spent submissions stay findable by their `queuedMessageId` link. */ export function settleRejectedQueuedMessage( db: Database.Database, @@ -240,20 +250,26 @@ export function settleRejectedQueuedMessage( consumedRef: string reason: string | null rejection: UnreadAgentSessionFailureFact | undefined + /** Who asked for the rejected hand-off (`AgentJournalSubmission.origin`). */ + origin: 'client' | 'host' | undefined now: number } ): boolean { - const settlement = rejectedDraftSettlement({ reason: input.reason, rejection: input.rejection }) + const settlement = rejectedDraftSettlement(input) const changed = settlement.state === 'waiting' ? db .prepare( `UPDATE queued_messages - SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, + SET state = 'waiting', hold_reason = ?, consumed_as = NULL, returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` ) - .run(input.sessionId, input.consumedRef) + .run( + settlement.kept ? QUEUED_MESSAGE_PAUSED_KEPT : null, + input.sessionId, + input.consumedRef + ) : db .prepare( `UPDATE queued_messages @@ -282,91 +298,5 @@ export function queuedMessagesSettledByOp( WHERE session_id = ? AND settled_by_op = ? ORDER BY position ASC` ) .all(sessionId, settledByOp) - .flatMap((row) => toStoredRow(row) ?? []) -} - -function toStoredRow(row: unknown): QueuedMessageRow | null { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. - const record = row as { - session_id: string - message_id: string - position: number - body_json: string - fingerprint: string - created_at: number - host_instance: string - state: string - hold_reason: string | null - returned_reason: string | null - returned_rejection: string | null - settled_at: number | null - settled_by_op: string | null - consumed_as: string | null - carried_from: string | null - queued_epoch: string | null - queued_sequence: number | null - source_json: string | null - } - let body: AgentJournalMessageItem - try { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. - body = JSON.parse(record.body_json) as AgentJournalMessageItem - } catch { - // Our own writer stringified it; an unreadable body is corruption, and a - // row we cannot re-materialize must not masquerade as an empty message. - return null - } - const state = record.state - if ( - state !== 'waiting' && - state !== 'dispatched' && - state !== 'returned' && - state !== 'withdrawn' - ) { - return null - } - return { - sessionId: record.session_id, - messageId: record.message_id, - position: record.position, - body, - fingerprint: record.fingerprint, - createdAt: record.created_at, - hostInstance: record.host_instance, - state, - holdReason: record.hold_reason, - returnedReason: record.returned_reason, - returnedRejection: storedRejection(record.returned_rejection), - settledAt: record.settled_at, - settledByOp: record.settled_by_op, - consumedAs: record.consumed_as, - carriedFrom: record.carried_from, - queuedAt: - record.queued_epoch !== null && typeof record.queued_sequence === 'number' - ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null, - source: storedSource(record.source_json) - } -} - -function storedSource(json: string | null): AgentSessionMessageSource { - let stored: unknown = null - try { - stored = json === null ? null : JSON.parse(json) - } catch { - // An unreadable value is read as no value; the source reader decides what that means. - } - return readAgentSessionMessageSource(stored) -} - -function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { - if (json === null) { - return null - } - try { - return readStoredRejectionFact(JSON.parse(json)) ?? null - } catch { - // The refusal stays readable from `returned_reason`; a bad fact must not lose the card. - return null - } + .flatMap((row) => readStoredQueuedMessageRow(row) ?? []) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 99900a2a893..7f7eb05fe1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -47,6 +47,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -648,18 +649,33 @@ describe('a start whose failure the delivery loop settles before the exit is pub }) describe('Stop withdraws what is queued', () => { - it('withdraws a crash leftover ahead of any delivery step (W17a)', async () => { + it('never meets a crash leftover: the open it runs settles it first (W17a)', async () => { await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ + ...earlierSubmission('person', 'p', true), + origin: 'client', + source: USER_MESSAGE_SOURCE, + fence + }) await journal.appendSubmission({ ...earlierSubmission('leftover', 'l', true), fence }) }) - // Stop's own open wakes the delivery loop, whose first step queues behind this Stop. expect(await stop()).toMatchObject({ ok: true }) + // A person's message is kept as a held card, which no Stop withdraws; the rest is rejected. + const hostRestarted = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + expect(await submission('person')).toMatchObject({ + dispatchState: 'rejected', + ...hostRestarted + }) expect(await submission('leftover')).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...hostRestarted }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual(['person']) expect(acquire).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 30c4fbbf933..4033c70de1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -14,7 +14,7 @@ import { createJournalOpenReadRefusals } from '../agent-session-journal/journal- import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' import { - abandonQueuedStructuredAgentSessionMessages, + holdClosedStructuredAgentSessionSends, closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, @@ -153,14 +153,14 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is - * still queued will not be sent. */ + * still queued will not be sent; a person's message stays as a held card. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { - // Abandoned before the stop, so no start delivers it. - await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) + // Settled before the stop, so no start delivers it. + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index dc3d4fc42ae..9c556916a39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -5,12 +5,14 @@ // process handed over and left unanswered as in doubt, and settles what it left running — the // crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A -// row an earlier process accepted and never handed over is the delivery loop's, which the open -// wakes. Nothing here starts a provider child. +// row an earlier process accepted and never handed over (it quit or crashed first) is settled here +// too, before any reader, command or child sees it: a person's message is kept as a held card, the +// rest rejected (`journal-unsent-send-hold.ts`). Nothing here starts a provider child. import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -21,6 +23,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -93,8 +96,7 @@ export async function openStructuredAgentSessionConversationJournal( deferPerSessionImport: options.deferPerSessionImport }) try { - // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over - // one is only doubt, which provider history decides under a won lease. + // A handed-over row found here is only doubt, which provider history decides under a won lease. await journal.markPendingSubmissionsUnknown(fence) } catch (error) { deps.logger.warn('marking pending sends unknown on open failed', { @@ -103,6 +105,22 @@ export async function openStructuredAgentSessionConversationJournal( error }) } + try { + // Before a Stop this open serves can withdraw one: a Stop never withdraws a card. + await holdUnsentSends(journal, { + fence, + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause: 'hostRestarted' } + }) + } catch (error) { + // The row stays queued. The delivery loop's first step tries again before it hands anything + // over; if that fails too, the loop fails and rejects every queued send. + deps.logger.warn('settling sends an earlier process left queued failed on open', { + scope: 'open-leftover-sends', + sessionId, + error + }) + } // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index e3aaa965edc..3d5d3705043 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -8,7 +8,9 @@ // record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop // that arrives while a start holds the queue withdraw the queued messages before the handover that // would have written them. Stop and the conversation's close are the only other writers of a -// queued message: a child's exit only ends the child, and this loop reads why. +// queued message: a child's exit only ends the child, and this loop reads why. A message an +// earlier host process left queued is never handed over: the open, or this loop's first step, +// settles it first (`journal-unsent-send-hold.ts`). import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -17,10 +19,9 @@ import { agentSessionFailureFact, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { @@ -58,8 +59,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number - /** Rejects queued messages as a completed close of the chat does; false when that failed. */ - abandonQueued: ( + /** Settles queued messages as a completed close of the chat does; false when that failed. */ + holdClosed: ( sessionId: string, which: (submission: AgentJournalSubmission) => boolean ) => Promise @@ -142,7 +143,7 @@ export class StructuredAgentSessionDeliveryLoop { await this.deps .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { - // Rows left queued are rejected by the next open, or by the next loop an accept wakes. + // Rows left queued go to the next loop an accept wakes, or the next open settles them. this.running.delete(sessionId) this.deps.logger.warn('recording a failed delivery failed', { scope: 'delivery-loop-fail', @@ -159,12 +160,14 @@ export class StructuredAgentSessionDeliveryLoop { if (!session || this.disposed) { return this.stop(sessionId) } - await session.journal.rejectQueuedSubmissions( - this.deps.conversationFence(sessionId), - agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { surface: 'rejection' }), - // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. - (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) - ) + // The open already did, unless its write failed: a row an earlier handle wrote is never handed + // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then + // fails, which rejects every queued send, this process's own too. + await holdUnsentSends(session.journal, { + fence: this.deps.conversationFence(sessionId), + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause: 'hostRestarted' } + }) if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) @@ -298,7 +301,7 @@ export class StructuredAgentSessionDeliveryLoop { return true } const { epoch } = session.journal.cursor() - return this.deps.abandonQueued( + return this.deps.holdClosed( sessionId, (submission) => ended.endedAt.epoch === epoch && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 80460bb72fa..d1bb1cfa794 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -3,7 +3,7 @@ // with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime' +import { holdClosedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openStructuredAgentSessionConversation, @@ -65,10 +65,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), - abandonQueued: async (sessionId, which) => { + holdClosed: async (sessionId, which) => { const session = sessions.get(sessionId) return session - ? abandonQueuedStructuredAgentSessionMessages(deps, sessionId, session.journal, which) + ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, which) : true }, failureTextContext: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 5e8d7e2097f..fda7bc9c27e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -10,8 +10,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { snapshotBeforeStructuredAgentSessionStop, StructuredAgentSessionEvictionError @@ -25,6 +24,7 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -60,33 +60,32 @@ type ConversationCloseDeps = Pick & { store: Pick } -/** A conversation's handle closes with nothing queued: what is still queued when the chat closes, - * or the app quits, will not be handed over. Best effort: the next open's delivery loop rejects a - * leftover itself. `which` narrows it to the messages a close that did not complete closed. - * Resolves false when the rejection failed; the failure is reported, never thrown. */ -export async function abandonQueuedStructuredAgentSessionMessages( +/** What is still queued when the chat closes will not be handed over: a person's message is kept + * as a held card, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the + * next open settles what it left. `which` narrows it to the messages a close that did not complete + * closed. Best effort, so a close never waits on it: resolves false when it failed, reported and + * never thrown. */ +export async function holdClosedStructuredAgentSessionSends( deps: ConversationCloseDeps, sessionId: string, journal: StructuredAgentSessionHostSession['journal'], which?: (submission: AgentJournalSubmission) => boolean ): Promise { - return journal - .rejectQueuedSubmissions( - structuredAgentSessionConversationFence(deps.store, sessionId), - agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }), - which - ) - .then( - () => true, - (error: unknown) => { - deps.logger.warn('rejecting queued messages of a closed chat failed', { - scope: 'queued-abandon', - sessionId, - error - }) - return false - } - ) + return holdUnsentSends(journal, { + fence: structuredAgentSessionConversationFence(deps.store, sessionId), + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause: 'chatClosed', ...(which ? { which } : {}) } + }).then( + () => true, + (error: unknown) => { + deps.logger.warn('settling queued messages of a closed chat failed', { + scope: 'queued-abandon', + sessionId, + error + }) + return false + } + ) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index ab632de9dda..ddfd7c25ff4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,7 +37,7 @@ import { setOptionPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' @@ -61,9 +61,10 @@ export function sendStructuredAgentSessionTurn( * Orchestration mail, a restart continuation and `agent.launch`'s host-sent * prompt never set it. */ userSend?: true - /** Host-local, never on the wire: who a host-side `queue-if-active` send queues for, recorded - * on its card. A client's send is always its person's (`userSend`). */ - source?: AgentMessageSource + /** Host-local, never on the wire: who a host-side send is from. A queued one records it on + * its card, a direct one its kind on the submission. A client's send is always its person's + * (`userSend`). */ + source?: AgentSessionMessageSource beforeRun?: () => void }, arrival?: Parameters[2] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index bbae1b4db16..fc93e4a43d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -1,9 +1,9 @@ // Host teardown, made failure-complete. // // Every phase runs whatever an earlier one threw: `flushAllEventSinks` throws BY DESIGN when a -// sink barrier fails, and the attach drain can reject too. Each conversation is then retired — -// what it still queues settled, its admitted writes drained — before the runtime closes the one -// journal connection, last. +// sink barrier fails, and the attach drain can reject too. Each conversation is then retired — its +// admitted writes drained, what it still queues left for its next open to keep — before the +// runtime closes the one journal connection, last. import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' @@ -11,7 +11,6 @@ import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { - abandonQueuedStructuredAgentSessionMessages, evictOwnedStructuredAgentSessions, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' @@ -109,8 +108,6 @@ async function tearDownStructuredAgentSessionHost(input: { sessions: Map retainSessionIds?: ReadonlySet acknowledgeSessionRelease?: (sessionId: string) => void - /** Quit closes every conversation, so it settles what they still queue as a close does. */ - abandonQueued?: (sessionId: string, session: StructuredAgentSessionHostSession) => Promise }): Promise { const failures: unknown[] = [] for (const phase of input.phases) { @@ -124,18 +121,18 @@ async function tearDownStructuredAgentSessionHost(input: { const entries = [...input.sessions.entries()].filter( ([sessionId]) => !input.retainSessionIds?.has(sessionId) ) - // `allSettled`, so one failed settlement cannot skip the others. + // Quit settles nothing still queued: delivery and the queue's drain are already disposed, so + // nothing hands it over now, and the next open settles it as it would after a crash. `allSettled`, so one failed close + // cannot skip the others. const closed = await Promise.allSettled( - entries.map(async ([sessionId, session]) => { - await input.abandonQueued?.(sessionId, session) + entries.map(async ([, session]) => { await session.journal.close() }) ) closed.forEach((result, index) => { const sessionId = entries[index]?.[0] if (result.status === 'fulfilled') { - // Only a settled conversation drops out. One whose queued sends could not be settled stays - // indexed, so a later stop retries that settlement. + // Only a closed conversation drops out; one whose close failed stays indexed for a retry. if (sessionId !== undefined) { input.sessions.delete(sessionId) input.acknowledgeSessionRelease?.(sessionId) @@ -180,10 +177,6 @@ export async function flushStructuredAgentSessionHost( sessions: context.sessions, retainSessionIds, acknowledgeSessionRelease: (sessionId) => - context.deps.adapter.acknowledgeSessionRelease?.(sessionId), - // Quit's is best effort: a failure is reported, and the next open rejects the leftover. - abandonQueued: async (sessionId, session) => { - await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) - } + context.deps.adapter.acknowledgeSessionRelease?.(sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 0b6a1fd89cf..b82ece68435 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -263,8 +263,10 @@ export class StructuredAgentSessionHost { // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. - /** Quit: no exit or recovery settled after this starts a child or hands a message over. */ - stopDelivery = (): void => this.conversationDelivery.dispose() + /** Quit: no exit or recovery settled after this starts a child or hands a message over, and the + * queue hands no card over. */ + stopDelivery = (): void => + [this.conversationDelivery, this.queued.drain].forEach((d) => d.dispose()) async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.stopDelivery() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 6a2a6268ae9..6190d74b51b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -8,6 +8,10 @@ // one that wrote nothing. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + USER_MESSAGE_SOURCE, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { @@ -77,6 +81,8 @@ export function sendPlan(params: { retryUnknown?: true delivery?: 'queue-if-active' userSend?: true + /** Who a host-side send is from; a person's send (`userSend`) is always the user. */ + source?: AgentSessionMessageSource beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -96,8 +102,10 @@ export function sendPlan(params: { run: (ctx) => { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() + const source = params.userSend ? USER_MESSAGE_SOURCE : params.source return performSend(ctx, { origin: params.userSend ? 'client' : 'host', + ...(source ? { source } : {}), clientMessageId, payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body @@ -105,8 +113,13 @@ export function sendPlan(params: { }, replay: (ctx, outcome) => { // A send this host queued answers from its draft, then its hand-off; a - // withdrawn draft replays as spent — never as missing-submission doubt. - const queued = queuedSendAnswer(ctx.journal, clientMessageId) + // withdrawn draft replays as spent — never as missing-submission doubt. Only a send that + // asked to be queued may get that answer: a direct send the host kept as a card answers + // from its own submission, which a client that never sent `delivery` can read. + const queued = + params.delivery === 'queue-if-active' + ? queuedSendAnswer(ctx.journal, clientMessageId) + : null if (queued) { return queued } @@ -163,6 +176,7 @@ export function conversationCommandPlan(params: { clientMessageId, // Only a client asks through the command RPC: the person's own turn. origin: 'client', + source: USER_MESSAGE_SOURCE, payloadFingerprint: params.envelope.payloadFingerprint, body: structuredAgentSessionCompactBody() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6f0c544f377..2551cd33237 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -153,9 +153,9 @@ function sendParams(text: string) { } } -async function accept(text: string): Promise { +async function accept(text: string, options: { person?: true } = {}): Promise { const params = sendParams(text) - const sent = await host.send(CALLER, params) + const sent = await host.send(CALLER, { ...params, ...(options.person ? { userSend: true } : {}) }) expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) return params.envelope.clientOperationId } @@ -611,7 +611,13 @@ describe('another child indexed while the loop waits on the one it started', () }) }) +// A quit settles nothing still queued: the next launch's open keeps a person's message as a held +// card and rejects the rest, as a crash's would. describe('a quit with a message still queued', () => { + const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + /** Read by the next launch, through the same open any reader takes. */ async function afterRelaunch(id: string): Promise { startHost() @@ -619,17 +625,30 @@ describe('a quit with a message still queued', () => { return await submission(id) } - it('settles a message no child ever had the way a chat close does (R2)', async () => { + async function keptCards(): Promise { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + return page.ok ? (page.page.queuedMessages ?? []).map((card) => card.messageId) : [] + } + + it('keeps a person’s message no child ever had as a held card for the next launch (R2)', async () => { // Quit has begun — its first step stops the delivery loops — when this message is accepted. host['conversationDelivery'].loop.dispose() - const id = await accept('hello') + const id = await accept('hello', { person: true }) expect(conversation()?.child).toBeNull() await host.flushAllStreamedEvents() - expect(await afterRelaunch(id)).toMatchObject({ - dispatchState: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) - }) + expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(await keptCards()).toEqual([id]) + expect(dispatch).not.toHaveBeenCalled() + }) + + it("rejects a message from Orca itself at the next launch, as a crash's would", async () => { + host['conversationDelivery'].loop.dispose() + const id = await accept('from orca') + await host.flushAllStreamedEvents() + + expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(await keptCards()).toEqual([]) }) it('waits for the start already in flight and stops the child it produced (R2)', async () => { @@ -644,7 +663,7 @@ describe('a quit with a message still queued', () => { await starting.promise return resolveRecovery(sessionId) }) - const id = await accept('hello') + const id = await accept('hello', { person: true }) await eventually(() => expect(recovering).toHaveBeenCalled()) const quit = host.flushAllStreamedEvents() @@ -654,10 +673,8 @@ describe('a quit with a message still queued', () => { expect(closeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) expect(dispatch).not.toHaveBeenCalled() - expect(await afterRelaunch(id)).toMatchObject({ - dispatchState: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) - }) + expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(await keptCards()).toEqual([id]) }) }) @@ -765,16 +782,38 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) + it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + const first = await accept('first', { person: true }) + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await closeStopOnly() + const starts = acquire.mock.calls.length + start.resolve() + await settleLoop() + + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + }) + it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { const { first, starts } = await closedWhileStarting(() => { const journal = conversation()!.journal - const reject = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(async (...args) => { - if (args[1].rejection.kind === 'chatClosed') { - vi.mocked(journal.rejectQueuedSubmissions).mockImplementation(reject) + const resolve = journal.resolveDispatch.bind(journal) + vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { + if (args[0].state === 'rejected' && args[0].rejection?.kind === 'chatClosed') { + vi.mocked(journal.resolveDispatch).mockImplementation(resolve) throw new Error('disk full') } - return reject(...args) + return resolve(...args) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts new file mode 100644 index 00000000000..864ad55f13c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -0,0 +1,120 @@ +// The scripted provider behind the queued-message rig: the adapter double the host drives, and +// the events it writes back as the provider would. + +import { vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' + +export type QueuedRigProviderOptions = { + /** A child started for a chat whose chain already names a thread resumes it, so a chat whose + * child closed or died can start another. */ + restartable?: true + /** Every child stays starting. */ + starting?: true + /** The provider's Stop ends its child, as Claude's does. */ + stopEndsSession?: true +} + +export function createQueuedRigProvider( + store: Pick, + options: QueuedRigProviderOptions +) { + // Admitted: the message is written and unanswered, so the session owes work + // until the test settles it. + const dispatch: Mock = vi.fn(async () => ({ + state: 'admitted' as const + })) + const awaitStarted: Mock> = vi.fn( + async () => undefined + ) + // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. + const compact: Mock> = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: null + })) + const cancelTurn: Mock = vi.fn(async () => ({ + cancelled: true + })) + const closeSession: Mock> = vi.fn( + async () => true + ) + let events: StructuredAgentSessionEventSink | undefined + + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ identity, fence, spawnToken, events: sink }) => { + events = sink + const resumes = + options.restartable === true && + (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: resumes ? ('resumed' as const) : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted, + closeSession, + releaseAcquisition: vi.fn(async () => true), + compact, + cancelTurn, + ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + } + + /** What the provider's translator writes when a /compact's turn ends, as a success. */ + function finishCompact(): void { + const { command } = compact.mock.calls.at(-1)![0] + events!.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) + } + + /** The event sink the provider writes through. */ + function providerEvents(): StructuredAgentSessionEventSink { + if (!events) { + throw new Error('no provider bound') + } + return events + } + + return { + adapter, + dispatch, + awaitStarted, + compact, + cancelTurn, + closeSession, + finishCompact, + providerEvents + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 668209d1513..bf2d1a5575e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -1,10 +1,10 @@ -// One real-host rig for the mid-turn queue suites: store, journal, adapter -// mocks, and the send/stop/draft helpers every suite shares. +// One real-host rig for the mid-turn queue suites: store, journal, the scripted provider +// (`...-rig-provider.test-fixture.ts`), and the send/stop/draft helpers every suite shares. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { expect, vi, type Mock } from 'vitest' +import { expect, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' @@ -12,9 +12,6 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' import { @@ -28,7 +25,10 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + createQueuedRigProvider, + type QueuedRigProviderOptions +} from './structured-agent-session-queued-message-rig-provider.test-fixture' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -40,80 +40,23 @@ export function eventually(assertion: () => void | Promise): Promise export type QueuedMessageTestRig = Awaited> -/** `restartable`: a child started for a chat whose chain already names a thread resumes it, so a - * chat whose child closed or died can start another. `starting`: every child stays starting. */ export async function createQueuedMessageTestRig( - options: { - restartable?: true - starting?: true + options: QueuedRigProviderOptions & { /** Lets a test sweep idle chats on its own `tick`. */ idleSweep?: { idleMs: number; intervalMs: number } - /** The provider's Stop ends its child, as Claude's does. */ - stopEndsSession?: true } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() - // Admitted: the message is written and unanswered, so the session owes work - // until the test settles it. - const dispatch: Mock = vi.fn(async () => ({ - state: 'admitted' as const - })) - const awaitStarted: Mock> = vi.fn( - async () => undefined - ) - // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. - const compact: Mock> = vi.fn(async () => ({ - state: 'accepted' as const, - providerIdentity: null - })) - const cancelTurn: Mock = vi.fn(async () => ({ - cancelled: true - })) - const closeSession: Mock> = vi.fn( - async () => true - ) - let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) + const provider = createQueuedRigProvider(store, options) + const { dispatch, awaitStarted, compact, cancelTurn, closeSession } = provider const makeHost = () => new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ identity, fence, spawnToken, events: sink }) => { - events = sink - const resumes = - options.restartable === true && - (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: resumes ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch, - awaitStarted, - closeSession, - releaseAcquisition: vi.fn(async () => true), - compact, - cancelTurn, - ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + adapter: provider.adapter, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', @@ -253,31 +196,6 @@ export async function createQueuedMessageTestRig( }) } - /** What the provider's translator writes when a /compact's turn ends, as a success. */ - function finishCompact(): void { - const { command } = compact.mock.calls.at(-1)![0] - events!.appendLifecycleBatch!( - `turn-completed:${command.clientMessageId}`, - [ - { - kind: 'item', - identity: command.identity, - body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ], - { lifecycle: true } - ) - } - - /** The event sink the provider writes through. */ - function providerEvents(): StructuredAgentSessionEventSink { - if (!events) { - throw new Error('no provider bound') - } - return events - } - /** A host-process restart, as the queue sees it: the conversation closes, and * opens afresh under a new instance id while its rows survive. The close is an eviction, whose * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that @@ -293,6 +211,12 @@ export async function createQueuedMessageTestRig( host = makeHost() } + /** The app quits — the host's own teardown runs — and a new host opens the same state. */ + async function quitRestartHostProcess(): Promise { + await host.flushAllStreamedEvents() + crashRestartHostProcess() + } + /** The queue's published pause: null when it sends on its own. */ async function queuePause(sessionId = SESSION): Promise { const page = await host.history({ sessionId, direction: 'tail' }) @@ -324,8 +248,8 @@ export async function createQueuedMessageTestRig( closeSession, awaitStarted, compact, - finishCompact, - providerEvents, + finishCompact: provider.finishCompact, + providerEvents: provider.providerEvents, envelope, send, stop, @@ -340,6 +264,7 @@ export async function createQueuedMessageTestRig( settleRejected, restartHostProcess, crashRestartHostProcess, + quitRestartHostProcess, queuePause, resume, dispose diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index d4c7c470f12..3d451a72e0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { USER_MESSAGE_SOURCE, - type AgentMessageSource + type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, @@ -198,7 +198,7 @@ export async function maybeQueueStructuredAgentSessionSend( /** A person's send at a chat surface; it outranks any `source`. */ userSend?: true /** Who a host-side send is from. */ - source?: AgentMessageSource + source?: AgentSessionMessageSource } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -271,9 +271,17 @@ export type QueuedMessageDrainDeps = { */ export class StructuredAgentSessionQueuedMessageDrain { private readonly scheduled = new Set() + private disposed = false constructor(private readonly deps: QueuedMessageDrainDeps) {} + /** Quit, with delivery: a hand-off made now could only be settled by the next process, so a + * quit leaves the cards exactly as a crash does. Read by the step at its start, and again + * right before it appends, since quit can land while it awaits. */ + dispose(): void { + this.disposed = true + } + schedule(sessionId: string): void { const journal = this.deps.sessions.get(sessionId)?.journal if (!journal) { @@ -319,7 +327,7 @@ export class StructuredAgentSessionQueuedMessageDrain { private async step(sessionId: string): Promise { const session = this.deps.sessions.get(sessionId) - if (!session) { + if (this.disposed || !session) { return } const journal = session.journal @@ -342,7 +350,7 @@ export class StructuredAgentSessionQueuedMessageDrain { // Live facts only, through the one gate; the backlog is never a gate, so a // lone draft drains. Whatever clears a hold publishes or commits, which // re-derives this step. - if (structuredQueueHold({ journal, record, fence }) !== null) { + if (this.disposed || structuredQueueHold({ journal, record, fence }) !== null) { return } // Always a fresh id: the submission names its draft by `queuedMessageId`, never by id equality. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 8554bb08154..0eb300fdef8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -7,12 +7,13 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionQueuedMessageDeleteResult, - AgentSessionQueuedMessagesResumeResult, - AgentSessionSendResult +import { + QUEUED_MESSAGE_PAUSED_KEPT, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionQueuedMessageDeleteResult, + type AgentSessionQueuedMessagesResumeResult, + type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' @@ -118,7 +119,11 @@ export async function carryQueuedMessagesToClearReplacement( fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId, - source: row.source + source: row.source, + // A kept send stays held there too: no later message may release it. + ...(row.holdReason === QUEUED_MESSAGE_PAUSED_KEPT + ? { holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + : {}) }) } await withdrawQueuedMessagesForOperation(ctx.journal, { @@ -314,7 +319,7 @@ export function deleteQueuedStructuredAgentMessage( /** Resume: ends the queue's pause — a Stop's, or a restart's — so the cards send * again, oldest first, as the session goes idle. A no-op when nothing is paused, - * and a per-card `send_failed` hold stays for its own Send. */ + * and a per-card hold (`send_failed`, `kept`) stays for its own Send. */ export function resumeStructuredAgentQueue( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 1fa46616da1..50f3b9ef270 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -4,8 +4,10 @@ // re-sending it. The two ride together: a client never sees one without the other. import { + QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, + type AgentSessionQueuedMessagePausedReason, type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -18,7 +20,8 @@ export type QueuePublication = { } /** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion); a Stop or a restart pauses the queue, published once beside it. */ + * conversion, or a send the host kept); a Stop or a restart pauses the queue, + * published once beside it. */ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { @@ -33,9 +36,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess state: row.state, ...(held ? { paused: true as const } : {}), // The stored reason is a typed marker; an unknown one reads as a plain hold. - ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED - ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } - : {}), + ...(held && isPublishedPausedReason(row.holdReason) ? { pausedReason: row.holdReason } : {}), ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), ...(row.state === 'returned' && row.returnedRejection ? { returnedRejection: row.returnedRejection } @@ -45,6 +46,12 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess return published } +function isPublishedPausedReason( + reason: string | null +): reason is AgentSessionQueuedMessagePausedReason { + return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED || reason === QUEUED_MESSAGE_PAUSED_KEPT +} + type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } /** Reference-stable per journal handle: an unchanged list is never diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 9abdc713f0e..d27d521c40e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -4,7 +4,7 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -17,7 +17,7 @@ export async function runQueueableStructuredAgentSessionSend( body: AgentJournalMessageItem delivery?: 'queue-if-active' userSend?: true - source?: AgentMessageSource + source?: AgentSessionMessageSource }, immediate: () => Promise> ): Promise> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts index 4dff44ff2ab..49d3d849ec3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -4,7 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' +import { + STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, + structuredAgentSessionRestartResumeSurfaces +} from './structured-agent-session-restart-resume-wiring' import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' const SESSION = 'session-1' @@ -111,3 +114,31 @@ describe('a restart batch holds a chat until its continuation is handed over', ( await expect(handedOver).resolves.toBeUndefined() }) }) + +// No source: Orca's own instruction is no person's, so a restart or a close rejects it, never keeps +// it as a card; the next restart offers the continuation again. +it('sends the continuation with no source, as Orca’s and no person’s', async () => { + const send = vi.fn(async () => { + throw new Error('refused') + }) + const surfaces = structuredAgentSessionRestartResumeSurfaces( + { + revealSession: async () => ({ readable: true }), + send, + waitForSendSettlement: async () => undefined + }, + () => 0 + ) + const envelope = { + sessionId: SESSION, + clientOperationId: MESSAGE, + expectedRuntimeFence: 2, + payloadFingerprint: 'fingerprint' + } + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + await expect(surfaces.send({ envelope, body })).rejects.toThrow('refused') + expect(send.mock.calls[0]).toEqual([ + { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, + { envelope, body } + ]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 4019582e1fd..23d2e589776 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -132,6 +132,9 @@ it('replays the same logical continuation through the durable send ledger', asyn if (!sent) { throw new Error('the continuation was not sent') } + // No person's: re-derived by its own records after another restart, so never kept as a card. + expect(sent).not.toHaveProperty('source') + expect((await host.journalSnapshot(SESSION)).submissions[0]).not.toHaveProperty('source') const replay = await host.send( { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, { envelope: sent.envelope, body: sent.body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index a1bb644d84e..d53d06f35a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -4,6 +4,7 @@ // every chat had its own file, and opens no file it does not restore. import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -41,6 +42,7 @@ import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-sess import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ readOnlyOpens: new Array(), @@ -114,7 +116,7 @@ function legacyDirFor(sessionId: string): string { } /** What the run before the upgrade left open in a chat, for its restore to settle. */ -type MidWork = 'running tool call' | 'unresolved send' +type MidWork = 'running tool call' | 'unresolved send' | 'never handed over' /** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ async function seedLegacyChat( @@ -134,7 +136,11 @@ async function seedLegacyChat( payloadFingerprint: 'fp-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, fence: 1, - handoverRecorded: true + handoverRecorded: true, + // A person's send that run accepted and quit before handing over. + ...(midWork === 'never handed over' + ? { origin: 'client' as const, source: USER_MESSAGE_SOURCE } + : {}) }) if (midWork === 'running tool call') { await journal.appendItem( @@ -143,6 +149,10 @@ async function seedLegacyChat( { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } + if (midWork === 'never handed over') { + await journal.close() + return writeLegacyChat(sessionId, scratch, journal.epoch) + } await journal.resolveDispatch( midWork === 'unresolved send' ? // Handed over, and never answered. @@ -172,11 +182,16 @@ async function seedLegacyChat( ) } await journal.close() - const rows = readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - sessionId, - journal.epoch - ) + return writeLegacyChat(sessionId, scratch, journal.epoch) +} + +/** Moves the rows a scratch host wrote into the chat's own per-chat file, as an older build kept it. */ +async function writeLegacyChat( + sessionId: string, + scratch: string, + epoch: string +): Promise { + const rows = readTestJournalRows(openTestJournalHostDatabase(scratch).db, sessionId, epoch) const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) await mkdir(dirname(path), { recursive: true }) const db = new Database(path) @@ -329,7 +344,7 @@ describe('startup restore of chats still in their per-chat files', () => { // Restore copies a chat only to write to it itself, settling what the last run left open (a // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. - it.each(['running tool call', 'unresolved send'] as const)( + it.each(['running tool call', 'unresolved send', 'never handed over'] as const)( 'copies during restore only a chat it settles (%s)', async (midWork) => { const rows = await seedLegacyChat('chat-mid-work', 1, midWork) @@ -350,6 +365,31 @@ describe('startup restore of chats still in their per-chat files', () => { } ) + // A send the last run never handed over is kept as a card in the same database the copy wrote, + // after the copy: the card and the rejected send both land behind the chat's own rows. + it('keeps a send the last run never handed over as a card, after the copy', async () => { + const rows = await seedLegacyChat('chat-kept', 0, 'never handed over') + + const { sessions } = await restore(['chat-kept']) + + const journal = sessions.get('chat-kept')!.journal + await journal.whenImported() + const copied = readTestJournalRows(hostDb(), 'chat-kept', rows[0]!.epoch) + expect(copied.slice(0, rows.length)).toEqual(rows) + expect(copied).toHaveLength(rows.length + 1) + expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({ + kind: 'dispatch', + clientMessageId: 'client-chat-kept', + state: 'rejected' + }) + const cards = hostDb() + .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') + .all('chat-kept') + expect(cards).toEqual([ + { message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' } + ]) + }) + it('lets other work run while it reads a large per-chat file', async () => { // Past one batch of the file's rows. const rows = await seedLegacyChat('chat-a', 520) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 2d5a9a133e4..8956e2d84fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -15,6 +15,7 @@ import type { AgentJournalMessageItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { refuse, type AgentSessionRefusalReason, @@ -132,6 +133,8 @@ export async function performSend( body: AgentJournalMessageItem /** Who asked for the turn; absent on callers that predate it. */ origin?: 'client' | 'host' + /** Who it is from; the submission keeps the kind only. */ + source?: AgentSessionMessageSource } ): Promise> { const existing = ctx.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts new file mode 100644 index 00000000000..0219207a6cc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -0,0 +1,652 @@ +// A message the host accepted while the agent was starting, then Orca quit or crashed, or the chat +// closed, before handing it over: it is kept as a held card at the head of the queue, never sent on +// its own, and released only by the person's own Send, Edit or Delete on it. Against the real host, +// store and journal. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { journalIdentityFor } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +/** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ +const MAIL_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [{ party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null } }], + orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } +} + +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) +const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' +}) +const KEPT = { state: 'waiting', paused: true } + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rig.dispose() +}) + +/** The exact request a client sends, so a test can send the same one again. */ +function sendRequest(text: string, delivery?: 'queue-if-active') { + const body = hostTestMessage(text) + const fields = { body, ...(delivery ? { delivery } : {}) } + return { + envelope: rig.envelope(fields, 'agentSession.send', hostTestOperationId()), + body, + ...(delivery ? { delivery } : {}), + userSend: true as const + } +} + +/** Accepted while the agent is starting, and never handed over: its start never finishes. */ +async function acceptWhileStarting( + request: Parameters[1] +): Promise { + // No child, so this send must start one. + await rig.host.close(SESSION, 'evict') + const startsBefore = rig.awaitStarted.mock.calls.length + rig.awaitStarted.mockImplementationOnce(() => new Promise(() => undefined)) + expect(await rig.host.send(QUEUED_RIG_CALLER, request)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBeGreaterThan(startsBefore)) + return request.envelope.clientOperationId +} + +function journal(): AgentSessionJournal { + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!open) { + throw new Error('the conversation is not open') + } + return open +} + +function dispatchedTexts(): string[] { + return rig.dispatch.mock.calls.map(([input]) => + input.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + ) +} + +describe('a message accepted while the agent starts, then Orca stops', () => { + it.each([ + { how: 'quit', restart: () => rig.quitRestartHostProcess() }, + { how: 'crash', restart: async () => rig.crashRestartHostProcess() } + ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { + const id = await acceptWhileStarting(sendRequest('hello after restart')) + await restart() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // Held on its own: no queue-wide pause, so no Resume to press. + expect(await rig.queuePause()).toBeNull() + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + origin: 'client', + source: { kind: 'user' } + }) + const [card] = journal().queuedMessages.list() + expect(card).toMatchObject({ + messageId: id, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + hostInstance: structuredAgentSessionHostInstance(), + body: hostTestMessage('hello after restart'), + queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } + }) + // Nothing is sent on its own, however many times the chat reopens. + await rig.host.close(SESSION, 'evict') + rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + }) + + it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { + const id = await acceptWhileStarting(sendRequest('kept through quit')) + await rig.quitRestartHostProcess() + // Read beside the host, without opening the chat through it: the row the quit left. + const leftover = await peekSubmission(id) + expect(leftover).toMatchObject({ dispatchState: 'pending', handoverRecorded: true }) + expect(leftover?.handedOverAt).toBeUndefined() + }) + + it('keeps several in the order they were accepted, ahead of the cards already queued', async () => { + const first = await acceptWhileStarting(sendRequest('first')) + const second = sendRequest('second') + expect(await rig.host.send(QUEUED_RIG_CALLER, second)).toMatchObject({ ok: true }) + await rig.crashRestartHostProcess() + + expect(await rig.drafts()).toEqual([ + { messageId: first, ...KEPT }, + { messageId: second.envelope.clientOperationId, ...KEPT } + ]) + }) +}) + +describe('only an action on the card releases a kept card', () => { + it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { + const id = await acceptWhileStarting(sendRequest('the kept words')) + await rig.quitRestartHostProcess() + + // The person types the same words again, as one who cannot see cards would. + const retyped = rig.send('the kept words') + await retyped.result + await eventually(async () => + expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(retyped.id, 'retyped') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(dispatchedTexts()).toEqual(['the kept words']) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + + await rig.resume() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.handoff(id)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + + expect(await rig.sendNow(id)).toMatchObject({ ok: true }) + const handoff = await rig.handoff(id) + expect(handoff?.clientMessageId).not.toBe(id) + expect(handoff).toMatchObject({ origin: 'client' }) + await eventually(() => expect(dispatchedTexts()).toEqual(['the kept words', 'the kept words'])) + expect(await rig.drafts()).toEqual([]) + }) + + it('Send now sends it at once, and Delete removes it', async () => { + const sent = await acceptWhileStarting(sendRequest('send me now')) + const deleted = sendRequest('delete me') + await rig.host.send(QUEUED_RIG_CALLER, deleted) + await rig.quitRestartHostProcess() + + expect(await rig.deleteQueued(deleted.envelope.clientOperationId)).toMatchObject({ + ok: true, + value: { deleted: true } + }) + expect(await rig.sendNow(sent)).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: sent, origin: 'client' } } + }) + await eventually(() => expect(dispatchedTexts()).toEqual(['send me now'])) + expect(await rig.drafts()).toEqual([]) + }) + + // Edit is the card's text in the composer, the card's Delete, then a new send. Neither leaves + // anything of the original send, even beside the sending desktop's own copy of it. + it.each(['Edit', 'Delete'] as const)('%s leaves no trace of the kept send', async (action) => { + const request = sendRequest('the kept words') + const id = await acceptWhileStarting(request) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(await rig.deleteQueued(id)).toMatchObject({ ok: true, value: { deleted: true } }) + const edited = action === 'Edit' ? rig.send('the edited words') : null + if (edited) { + await edited.result + await eventually(async () => + expect((await rig.submission(edited.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(edited.id, 'edited') + } + + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + const lingering = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: SESSION, + text: 'the kept words', + attachments: [], + queuedAt: 1 + }), + state: 'rejected' as const + } + const shown = projectStructuredAgentSessionMessages( + page.page.items, + [lingering], + page.page.submissions, + // The desktop's transcript, which draws a rejected send in place unless it was kept. + { rejectedInPlace: true } + ).map((message) => ({ + text: message.blocks.map((block) => ('text' in block ? block.text : '')).join(''), + unsent: message.unsent ?? false + })) + expect(shown).toEqual(action === 'Edit' ? [{ text: 'the edited words', unsent: false }] : []) + expect(await rig.drafts()).toEqual([]) + }) + + it('a Stop on the reopened chat leaves it kept: the open settles it before the Stop runs', async () => { + const id = await acceptWhileStarting(sendRequest('survives a stop')) + await rig.crashRestartHostProcess() + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + }) + + // A Send now cut short by a quit hands the card back kept, so a retyped copy still goes alone. + // After a second restart the kept card is another process's, yet it pauses nothing else. + it('a later restart leaves the cards queued after it unpaused', async () => { + const id = await acceptWhileStarting(sendRequest('kept twice over')) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + rig.crashRestartHostProcess() + const working = await rig.workingSend() + const later = rig.send('queued after the restart', 'queue-if-active') + expect(await later.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + + expect(await rig.queuePause()).toBeNull() + await rig.settleAccepted(working, 'working') + await eventually(() => + expect(dispatchedTexts()).toEqual(['work on this', 'queued after the restart']) + ) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + }) + + it('stays kept when its own Send is cut short by another quit', async () => { + const id = await acceptWhileStarting(sendRequest('the kept words')) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // A /compact runs, so the Send now waits behind it, and Orca quits before it is handed over. + const fields = { command: 'compact' as const } + expect( + await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + expect(await rig.sendNow(id)).toMatchObject({ ok: true }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect((await rig.handoff(id))?.handedOverAt).toBeUndefined() + await rig.quitRestartHostProcess() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + const retyped = rig.send('the kept words') + await retyped.result + await eventually(async () => + expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(retyped.id, 'retyped') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(dispatchedTexts()).toEqual(['the kept words']) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + }) +}) + +// The queue stops with delivery at quit, so a quit makes no hand-off only the next process could +// settle: the queued cards come back as a crash leaves them, under the restart's pause. +describe('cards queued behind a working turn, then Orca stops', () => { + it.each(['quit', 'crash'] as const)( + 'after a %s they wait under the restart pause, not kept, with no hand-off made', + async (how) => { + await rig.workingSend() + const first = rig.send('queued behind work', 'queue-if-active') + expect(await first.result).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + const second = rig.send('second queued behind work', 'queue-if-active') + expect(await second.result).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + if (how === 'quit') { + await rig.quitRestartHostProcess() + } else { + rig.crashRestartHostProcess() + } + + expect(await rig.drafts()).toEqual([ + { messageId: first.id, state: 'waiting' }, + { messageId: second.id, state: 'waiting' } + ]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.handoff(first.id)).toBeUndefined() + expect(journal().queuedMessages.get(first.id)?.holdReason).toBeNull() + } + ) +}) + +describe('the queue at a quit', () => { + /** A /compact the provider took; its end, written later by `finishCompact`, wakes the drain. */ + async function compactRunning(): Promise { + const fields = { command: 'compact' as const } + expect( + await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + } + + // Quit's first step is `stopDelivery`, before teardown drains recovery; the flush repeats it. + it.each(['stopDelivery', 'flushAllStreamedEvents'] as const)( + 'a drain step already running when quit begins (%s) makes no hand-off', + async (quitStep) => { + await compactRunning() + const queued = rig.send('queued behind the compact', 'queue-if-active') + expect(await queued.result).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + const host = rig.host + const { queuedMessages } = journal() + const settleOwed = queuedMessages.settleOwed.bind(queuedMessages) + let release = (): void => undefined + const held = new Promise((resolve) => (release = resolve)) + let reached = (): void => undefined + const inStep = new Promise((resolve) => (reached = resolve)) + let blocked = false + // Holds the drain step at its one await, past its first dispose check, until quit has begun. + // Only the drain step heals owed bookkeeping, so no caller check is needed (nor a stack read, + // which runtimes format differently). + const owed = vi.spyOn(queuedMessages, 'settlementOwed').mockImplementation(() => !blocked) + const healing = vi.spyOn(queuedMessages, 'settleOwed').mockImplementation(async () => { + if (!blocked) { + blocked = true + reached() + await held + } + return settleOwed() + }) + rig.finishCompact() + await inStep + if (quitStep === 'stopDelivery') { + host.stopDelivery() + release() + await healing.mock.results[0]?.value + // The step's append check runs on the turn after its heal resolves. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(await rig.handoff(queued.id)).toBeUndefined() + } else { + const quitting = host.flushAllStreamedEvents() + release() + await quitting + } + owed.mockRestore() + healing.mockRestore() + rig.crashRestartHostProcess() + + expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) + + // The card the person pushed ahead waits for their own Send; Resume sends the rest. + it('Send now on an ordinary card, cut short by a quit, returns it kept while Resume sends the rest', async () => { + await compactRunning() + const first = rig.send('first queued', 'queue-if-active') + await first.result + const pushed = rig.send('pushed ahead', 'queue-if-active') + await pushed.result + expect(await rig.sendNow(pushed.id)).toMatchObject({ ok: true }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect((await rig.handoff(pushed.id))?.handedOverAt).toBeUndefined() + await rig.quitRestartHostProcess() + + expect(await rig.drafts()).toEqual([ + { messageId: pushed.id, ...KEPT }, + { messageId: first.id, state: 'waiting' } + ]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await rig.resume() + await eventually(() => expect(dispatchedTexts()).toEqual(['first queued'])) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.drafts()).toEqual([{ messageId: pushed.id, ...KEPT }]) + }) +}) + +// The same rule as a restart: tab close, worktree teardown and an orchestration stop all close the +// chat, and the chat can be reopened from its history. +describe('a message accepted while the agent starts, then the chat closes', () => { + it.each(['user-close', 'evict'] as const)( + 'after a %s it is a held card the reopened chat shows, rejected as closed', + async (cause) => { + const id = await acceptWhileStarting(sendRequest('kept at close')) + await rig.host.close(SESSION, cause) + rig.crashRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + // The desktop drops its own copy at tab close; the card is what shows the words. + const texts = projectStructuredAgentSessionMessages( + page.page.items, + [], + page.page.submissions, + { rejectedInPlace: true } + ) + .flatMap((shown) => shown.blocks.map((block) => ('text' in block ? block.text : ''))) + .join('|') + expect(texts).not.toContain('kept at close') + expect(page.page.queuedMessages?.[0]).toMatchObject({ + body: hostTestMessage('kept at close'), + pausedReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) +}) + +// The desktop's outbox keeps an unconfirmed send and sends it again, under the same id, once the +// app is back. That replay meets the card the open made from the same send. +describe('the same send arriving again after the restart', () => { + it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { + const request = sendRequest('sent again by the outbox') + const id = await acceptWhileStarting(request) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + const submissionsBefore = (await rig.host.journalSnapshot(SESSION)).submissions.length + + const again = await rig.host.send(QUEUED_RIG_CALLER, request) + + // Never the queued arm, which only a client that sent `delivery` can read; the card it sees + // under the same id is what tells it the host holds the message. + expect(again).toMatchObject({ + ok: true, + replayed: true, + value: { clientMessageId: id, submission: { dispatchState: 'rejected', ...HOST_RESTARTED } } + }) + expect(again.ok && 'queued' in again.value).toBe(false) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(submissionsBefore) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + + // Retry on such a client sends the words under a new id: delivered once, the card still held. + const retried = rig.send('sent again by the outbox') + await retried.result + await eventually(async () => + expect((await rig.submission(retried.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(retried.id, 'retried') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(dispatchedTexts()).toEqual(['sent again by the outbox']) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + }) + + it('a client that asked to queue is told the host holds it', async () => { + const request = sendRequest('queued-capable resend', 'queue-if-active') + const id = await acceptWhileStarting(request) + await rig.crashRestartHostProcess() + + expect(await rig.host.send(QUEUED_RIG_CALLER, request)).toMatchObject({ + ok: true, + value: { clientMessageId: id, queued: { messageId: id, state: 'waiting' } } + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + }) +}) + +describe('what is not kept', () => { + // Mail names its sender (an agent); a dispatch preamble names none. Neither is a person's. + it.each([ + { by: 'mail', source: MAIL_SOURCE, recorded: { kind: 'agent' } }, + { by: 'dispatch', source: undefined, recorded: undefined } + ])('an orchestration $by send is rejected, as before', async ({ by, source, recorded }) => { + const { userSend: _person, ...sent } = sendRequest(by) + await acceptWhileStarting({ ...sent, ...(source ? { source } : {}) }) + await rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([]) + const submission = await rig.submission(sent.envelope.clientOperationId) + expect(submission).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(submission?.source).toEqual(recorded) + }) + + it('a send whose card could not be written is rejected as before, and nothing stays queued', async () => { + const id = await acceptWhileStarting(sendRequest('card write fails')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(JournalQueuedMessages.prototype, 'holdInTransaction').mockImplementation(() => { + throw new Error('disk full') + }) + rig.crashRestartHostProcess() + + await eventually(async () => + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED + }) + ) + expect(await rig.drafts()).toEqual([]) + // No card holds it, so its rejection names none. + expect(await rig.submission(id)).not.toHaveProperty('keptAsQueuedMessageId') + expect(warned).toHaveBeenCalledWith( + '[journal-hold] keeping an unsent send failed:', + expect.objectContaining({ clientMessageId: id, cause: 'hostRestarted' }) + ) + }) + + it('the delivery step keeps one the open could not settle, and hands nothing over', async () => { + const id = await acceptWhileStarting(sendRequest('open write failed')) + const resolve = AgentSessionJournal.prototype.resolveDispatch + // The open's keep and its plain-rejection fallback both fail. + let failures = 2 + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(function ( + this: AgentSessionJournal, + ...args: Parameters + ) { + if (failures > 0 && args[0].clientMessageId === id) { + failures -= 1 + return Promise.reject(new Error('disk busy')) + } + return resolve.apply(this, args) + }) + await rig.quitRestartHostProcess() + // The open's write failed; a new send wakes the delivery loop, whose first step settles it. + const next = rig.send('wakes the loop') + await next.result + await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }])) + await eventually(() => expect(dispatchedTexts()).toEqual(['wakes the loop'])) + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + }) +}) + +describe('/clear carries a kept card still held', () => { + it('a turn in the new conversation never releases it', async () => { + const id = await acceptWhileStarting(sendRequest('carried through clear')) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toHaveLength(1) + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) + const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal + expect(carried?.queuedMessages.list()[0]?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + + const body = hostTestMessage('first turn after the clear') + const turn = await rig.host.send(QUEUED_RIG_CALLER, { + envelope: { + sessionId: replacementId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: replacementId, + fields: { body } + }) + }, + body, + userSend: true + }) + const turnId = turn.ok ? turn.value.clientMessageId : '' + await eventually(async () => + expect( + (await rig.host.journalSnapshot(replacementId)).submissions.find( + (entry) => entry.clientMessageId === turnId + )?.handedOverAt + ).toBeDefined() + ) + await rig.host.settleLateDispatch({ + sessionId: replacementId, + clientMessageId: turnId, + providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) + expect(dispatchedTexts()).toEqual(['first turn after the clear']) + }) +}) + +/** The submission as the quit left it, read from a journal opened beside the host's. */ +async function peekSubmission(id: string) { + const record = rig.store.getRecord(SESSION)! + const params = attachParamsForRecord(record, { + clientOperationId: 'peek', + expectedRuntimeFence: record.lease.runtimeFence + }) + const peeked = await openAgentSessionJournal({ + identity: journalIdentityFor(record, params), + database: openTestJournalHostDatabase(rig.root) + }) + try { + return peeked.submission(id) + } finally { + await peeked.close() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 217360673fc..9f42431e773 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -182,7 +182,7 @@ export function structuredAgentSessionWorkingAtStop(input: { return null } const snapshot = session.journal.snapshot() - // A queued message reached no agent, so it is no work to resume: quit rejects it as never sent. + // A queued message reached no agent, so it is no work to resume: the next open settles it. const handedOver = snapshot.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 22fa2b41528..d3bb13bafa3 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -3,10 +3,8 @@ // was open before the command saw, or the journal a client would load. import { beforeEach, expect, it, vi, type Mock } from 'vitest' -import { - AgentJournalSubmissionSchema, - isAdmissibleAgentJournalItemBody -} from '../../../shared/agent-session-journal-schemas' +import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, diff --git a/src/main/runtime/orchestration/send-agent-turn-host.test.ts b/src/main/runtime/orchestration/send-agent-turn-host.test.ts index 0f92d95cee8..9ba15ea1765 100644 --- a/src/main/runtime/orchestration/send-agent-turn-host.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-host.test.ts @@ -125,8 +125,10 @@ describe('sendAgentTurn through the real host', () => { }) /** Settles a handed-over send as the provider taking it; the turn's wait ends on that. */ - async function sendTurnAccepted(delivery: AgentTurnDelivery) { - const operationId = hostTestOperationId() + async function sendTurnAccepted( + delivery: AgentTurnDelivery, + operationId = hostTestOperationId() + ) { const outcome = sendTurn(delivery, operationId) await eventually(async () => expect((await rig.submission(operationId))?.handedOverAt).toBeDefined() @@ -143,6 +145,16 @@ describe('sendAgentTurn through the real host', () => { expect(await rig.drafts()).toEqual([]) }) + // An idle chat sends the mail at once: its submission says it is an agent's, without the senders + // the card keeps host-only, so a restart or a close rejects it rather than keep it as a card. + it('records an idle chat’s mail as an agent’s, by kind only', async () => { + const operationId = hostTestOperationId() + await sendTurnAccepted('queue', operationId) + const submission = await rig.submission(operationId) + expect(submission?.source).toEqual({ kind: 'agent' }) + expect(JSON.stringify(submission)).not.toContain('term_peer') + }) + it('has a `now` send join the running turn, never the queue', async () => { await rig.workingSend() await expect(sendTurnAccepted('now')).resolves.toMatchObject({ diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts index 1b591051936..55717cca96b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -52,6 +52,8 @@ describe('committing a launch prompt', () => { expect(messageId).toBe(params.envelope.clientOperationId) expect(params.envelope).toMatchObject({ sessionId: 'sess-1', expectedRuntimeFence: 4 }) expect(params.body).toEqual(structuredAgentSessionSendBody('do the thing', [])) + // A restart keeps a launch's first prompt like a person's message, so the send says it is one. + expect(params.source).toEqual({ kind: 'user' }) // The host recomputes and compares this, so a launch send must fingerprint like a client send. expect(params.envelope.payloadFingerprint).toBe( structuredAgentSessionPayloadFingerprint({ diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 00c779fffe0..e18f557163d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -24,6 +24,7 @@ import { createStructuredAgentSessionOperationId } from '../../../../shared/stru import { randomUUID } from 'node:crypto' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' +import { USER_MESSAGE_SOURCE } from '../../../../shared/agent-session-message-source' /** * The committed transcript row's id, or `null` when nothing was committed. @@ -54,10 +55,11 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { queuedAt: Date.now() }) try { - const result = await args.host.send( - args.caller, - structuredAgentSessionSendMutation(entry, args.fence) - ) + const result = await args.host.send(args.caller, { + ...structuredAgentSessionSendMutation(entry, args.fence), + // A person's first prompt, sent for them: kept as a card if a restart or a close comes first. + source: USER_MESSAGE_SOURCE + }) return result.ok ? result.value.clientMessageId : null } catch (error) { // Settlement can fail after the journal append. Re-read the authoritative row before asking diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index b501acc74c6..02797d54d6c 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -273,6 +273,15 @@ describe('structured worker dispatch preamble', () => { const send = (host: PreambleHost) => sendStructuredWorkerPreamble({ host, sessionId: 's1', dispatchId: 'd1', preamble: 'spec' }) + // No source and no person: a restart or a close rejects it, and orchestration re-derives it. + it('sends the preamble as no person’s', async () => { + const host = hostWithSubmission({ dispatchState: 'accepted', reason: null }) + const sent = vi.spyOn(host, 'send') + await send(host) + expect(sent.mock.calls[0]?.[1]).not.toHaveProperty('source') + expect(sent.mock.calls[0]?.[1]).not.toHaveProperty('userSend') + }) + it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts index a4b68ccbcd3..259ee2dbe14 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -1,6 +1,6 @@ // The queued-message actions: Send-now and Delete on one card, and Resume on a -// paused queue. All gated on agent-session.queued-messages.v1; an older host -// lacks the methods entirely. +// paused queue. Not gated on agent-session.queued-messages.v1: a host without it +// still publishes the cards it kept unsent. A host older than the queue lacks them. import { defineMethod } from '../core' import { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx index d86b5bc394b..06bc89d8c16 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx @@ -10,8 +10,6 @@ import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] - let restoreViewport = (): void => {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -44,7 +42,7 @@ function transcript(items: AgentJournalRenderItem[]) { return ( Promise.resolve('exhausted' as const) function Transcript({ items }: { items: AgentJournalRenderItem[] }) { - const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY, EMPTY) + const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY) const session: NativeChatLiveSession = { messages, status: 'working', diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx index 3578cec8f0f..28affc5512d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx @@ -26,8 +26,6 @@ import { structuredAgentSessionDeliveryNotices } from './structured-agent-sessio import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] - let restoreViewport = (): void => {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -139,7 +137,7 @@ function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutbo return ( void onDelete: () => void onEdit: () => void - onTurnOffQueueing: () => void + /** Absent when the host does not queue sends, so there is nothing to turn off. */ + onTurnOffQueueing?: () => void }): React.JSX.Element { const caption = queuedMessageCardCaption(card) const returned = card.state === 'returned' @@ -201,12 +211,14 @@ export function NativeChatQueuedMessageCard({ {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} - - {translate( - 'components.native-chat.queuedMessages.turnOffQueueing', - 'Turn off queueing' - )} - + {onTurnOffQueueing ? ( + + {translate( + 'components.native-chat.queuedMessages.turnOffQueueing', + 'Turn off queueing' + )} + + ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 41fd4852a20..84d6dc4480a 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -46,7 +46,8 @@ function card(overrides: Partial & { messageId: string }): Qu function controller( cards: QueuedMessageCard[], - pause: { reason: string } | null = null + pause: { reason: string } | null = null, + queueCapable = true ): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType @@ -55,6 +56,7 @@ function controller( } { return { cards, + queueCapable, pause, resume: vi.fn(async () => {}), resuming: false, @@ -440,6 +442,23 @@ describe('NativeChatQueuedMessageList', () => { expect(screen.getAllByRole('button', { name: 'Send' }).length).toBeGreaterThan(0) }) + // A message the host kept after a restart or a close is not a mid-turn steer: on an idle chat + // its action is plainly Send, and its caption says it was never sent. + it('a kept card says it was not sent yet and offers Send, with no Resume over it', async () => { + renderList( + controller([card({ messageId: 'held', hold: 'paused', pausedReason: 'kept' })], { + reason: 'restarted' + }) + ) + const [row] = screen.getAllByRole('listitem') + expect(row?.textContent).toContain('Not sent yet — press Send to send it.') + expect(row?.textContent).not.toContain('kept') + fireEvent.focus(screen.getByRole('button', { name: 'Send' })) + expect((await screen.findAllByText('Send this message now')).length).toBeGreaterThan(0) + expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + }) + it('an absent or unknown pause marker reads as a plain pause, never raw', () => { renderList( controller([ @@ -470,4 +489,32 @@ describe('NativeChatQueuedMessageList', () => { fireEvent.click(await screen.findByRole('menuitem', { name: 'Turn off queueing' })) expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) + + // A kept message shows as a card even where the host does not queue sends; there the setting + // and the chord would do nothing, so neither is offered. + it.each([ + { queueCapable: true, offered: true }, + { queueCapable: false, offered: false } + ])( + 'offers Turn off queueing and the send chord only when the host queues sends ($queueCapable)', + async ({ queueCapable, offered }) => { + const owner = controller( + [card({ messageId: 'kept', hold: 'paused', pausedReason: 'kept' })], + null, + queueCapable + ) + renderList(owner) + fireEvent.focus(screen.getByRole('button', { name: 'Send' })) + const hint = await screen.findAllByText('Send this message now') + expect(hint[0]!.parentElement!.children).toHaveLength(offered ? 2 : 1) + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + expect(await screen.findByRole('menuitem', { name: 'Edit message' })).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: 'Turn off queueing' }) !== null).toBe(offered) + // Send, Delete and Edit work either way. + fireEvent.click(screen.getByRole('menuitem', { name: 'Edit message' })) + expect(owner.edit).toHaveBeenCalledWith('kept') + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(owner.steer).toHaveBeenCalledWith('kept') + } + ) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 9c47a8c375e..b0960a4410b 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -23,6 +23,10 @@ export function NativeChatQueuedMessageList({ const queueRef = useRef(null) const { cards } = controller const newest = cards.at(-1) + // Only a host that queues sends has queueing to turn off; a kept card shows without it. + const turnOffQueueing = controller.queueCapable + ? () => void updateSettings({ nativeChatQueueFollowUps: false }) + : undefined // A pause over cards Resume would not send (returned, held on their own, or behind a returned // one) offers nothing to press. const pause = cards.some((card) => card.hold === 'queue-paused') ? controller.pause : null @@ -60,11 +64,11 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} - onTurnOffQueueing={() => void updateSettings({ nativeChatQueueFollowUps: false })} + onTurnOffQueueing={turnOffQueueing} /> ))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 8f43b043c81..7c8d6cf5d22 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -129,7 +129,6 @@ export function NativeChatStructuredSession( submissions: controller.submissions, journalItems: controller.journalItems, failedHere: controller.failedHere, - queuedMessageIds: controller.queuedMessageIds, retry: controller.retry, agentName: agentLabel }) diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 16c9a30a397..6d4e0b23a78 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -19,8 +19,6 @@ import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] - function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } } @@ -70,7 +68,7 @@ const JOURNAL: AgentJournalRenderItem[] = [ /** The renderer's own path from journal items to rail items, as the list runs it. */ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { const projected = createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS) + projectStructuredAgentSessionMessages(items, [], submissions) ).conversation const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected)) let turn: string | undefined diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts new file mode 100644 index 00000000000..3e636a0e558 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts @@ -0,0 +1,80 @@ +// A send the host kept as a queued card is the card's from then on: its own row says nothing, +// neither "Sending…" nor not sent, whatever state the desktop's saved copy was left in. + +import { expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' + +const KEPT_ID = 'client-kept' + +function savedCopy( + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: KEPT_ID, + sessionId: 'session-1', + text: 'the kept words', + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +const restartRejected: AgentJournalSubmission = { + clientMessageId: KEPT_ID, + fence: 2, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true +} + +const kept: AgentJournalSubmission = { ...restartRejected, keptAsQueuedMessageId: KEPT_ID } + +function notices( + copy: StructuredAgentSessionOutboxEntry, + submissions: readonly AgentJournalSubmission[] +) { + return structuredAgentSessionDeliveryNotices( + [copy], + 'Claude', + () => {}, + submissions, + [], + new Set() + ) +} + +// The saved copy's states after a quit: still going out, resent on its own after a lost answer, +// or already marked not sent. +const COPIES = [ + savedCopy(), + savedCopy({ state: 'dispatching' }), + savedCopy({ state: 'unconfirmed', retryAfterUnknownSubmittedAt: null }), + savedCopy({ state: 'rejected', lastFailure: { kind: 'rejected', reason: 'not sent' } }) +] + +it('gives a kept send no notice, so its saved copy never reads "Sending…" or not sent', () => { + for (const copy of COPIES) { + expect([...notices(copy, [kept])]).toEqual([]) + } +}) + +it('still says not sent, never "Sending…", for a send rejected without being kept', () => { + for (const copy of COPIES) { + const notice = notices(copy, [restartRejected]).get(agentJournalSubmissionKey(KEPT_ID)) + expect(notice).toMatchObject({ text: expect.any(String) }) + expect(notice).not.toHaveProperty('sending') + } +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 9054c35b533..3ed26650ed4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -597,7 +597,6 @@ describe('the notice on each message that did not go through', () => { [rejected], [], NOT_FAILED_HERE, - [], new Set(), [loadedRow] ) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 52f3832dffa..ef91f8c1920 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -164,8 +164,6 @@ export function structuredAgentSessionDeliveryNotices( startFailures: readonly AgentSessionFailureFact[], /** Ids whose send failed or was refused while this chat was open: only they word their cause. */ failedHere: ReadonlySet, - /** The queue's live cards, which the transcript leaves a rejected message to. */ - queuedMessageIds: readonly string[] = [], /** The loaded commands, from `structuredAgentSessionCommandItemIds`: they report their own. */ commandItemIds: ReadonlySet = NO_COMMANDS, /** The loaded rows: a rejected message's outbox copy leaves once its row is here. */ @@ -214,11 +212,7 @@ export function structuredAgentSessionDeliveryNotices( } } // After the outbox's: in the host's words, whether its row or the outbox's copy draws it. - const shown = structuredAgentSessionRejectedShownInPlace( - submissions, - queuedMessageIds, - commandItemIds - ) + const shown = structuredAgentSessionRejectedShownInPlace(submissions, commandItemIds) for (const submission of rejected.values()) { const id = agentJournalSubmissionKey(submission.clientMessageId) if (shown.has(id)) { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts index 7cc13a0a4eb..0c5d273bc54 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts @@ -19,7 +19,6 @@ import { import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] const MESSAGE_ID = agentJournalSubmissionKey('m') function answer(sequence: number): AgentJournalRenderItem { @@ -126,7 +125,7 @@ it("keeps the outbox copy of a rejected message whose row is outside the window, ) expect(kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }]) const drawn = (outbox: typeof kept) => - projectStructuredAgentSessionMessages(state.items, outbox, state.submissions, NO_CARDS) + projectStructuredAgentSessionMessages(state.items, outbox, state.submissions) .filter((message) => message.role === 'user') .map(({ id, unsent }) => ({ id, unsent })) expect(drawn(kept)).toEqual([{ id: MESSAGE_ID, unsent: true }]) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts index 84e10ca18b7..c27204b91f5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts @@ -25,7 +25,6 @@ import { import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] const MESSAGE_ID = agentJournalSubmissionKey('m') const WORDS = 'Orca restarted before this message was sent.' @@ -122,7 +121,7 @@ function shown( state.submissions, state.items ) - const rows = projectStructuredAgentSessionMessages(state.items, kept, state.submissions, NO_CARDS) + const rows = projectStructuredAgentSessionMessages(state.items, kept, state.submissions) .filter((message) => message.role === 'user') .map(({ id, unsent }) => ({ id, unsent })) const notice = structuredAgentSessionDeliveryNotices( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 4e757705225..4d94622cfa9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -22,8 +22,6 @@ import { import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] - const SESSION = 'session-1' function body(text: string) { @@ -133,8 +131,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const messages = projectStructuredAgentSessionMessages( items, [], - [SEED, restartRejected('lost', 'fix the parser', 3)], - NO_CARDS + [SEED, restartRejected('lost', 'fix the parser', 3)] ) expect(rows(messages)).toEqual([ @@ -154,8 +151,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const messages = projectStructuredAgentSessionMessages( items, [], - [SEED, restartRejected('waited', 'fix the parser', 3)], - NO_CARDS + [SEED, restartRejected('waited', 'fix the parser', 3)] ) expect( @@ -212,7 +208,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => submission('resent', 'retry me', 4) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('resent'), text: 'retry me', unsent: false } ]) @@ -227,7 +223,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => submission('again', 'continue', 4) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false }, // Listed after the delivered rows; its journal position keeps its place. @@ -244,7 +240,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => restartRejected('b', 'pointer', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('b'), text: 'pointer', unsent: true } ]) @@ -264,7 +260,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => withdrawn('stopped', 'again', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } @@ -280,9 +276,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const submissions = [SEED, restartRejected('compact', '/compact', 3)] expect( - rows( - projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions, NO_CARDS) - ) + rows(projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions)) ).toEqual([{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }]) // One rule decides for the rows and the notices. expect( @@ -293,7 +287,6 @@ describe('a message the host accepted and then rejected, on the desktop', () => submissions, [], new Set(), - NO_CARDS, structuredAgentSessionCommandItemIds([...SEED_ROWS, compact]) ).size ).toBe(0) @@ -303,7 +296,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')] const submissions = [SEED, withdrawn('stopped', 'never mind', 3)] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } ]) expect( @@ -329,7 +322,7 @@ describe("one row per rejected message, the host's once it records the rejection const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } it('the reply first: the outbox draws it, saying why, with no Retry', () => { - const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED], NO_CARDS) + const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED]) expect(rows(messages)).toEqual([seedRow, heldRow]) const notice = structuredAgentSessionDeliveryNotices( [held], @@ -345,17 +338,13 @@ describe("one row per rejected message, the host's once it records the rejection it("the journal first, or next: the host's row replaces the outbox copy at once", () => { const dispatching = { ...held, state: 'dispatching' as const, lastFailure: undefined } - expect( - rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED], NO_CARDS)) - ).toEqual([seedRow, { ...heldRow, unsent: false }]) + expect(rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED]))).toEqual([ + seedRow, + { ...heldRow, unsent: false } + ]) for (const entry of [dispatching, held]) { // Before the reconcile drops the entry, the host's row is already the one row. - const messages = projectStructuredAgentSessionMessages( - items, - [entry], - [SEED, rejected], - NO_CARDS - ) + const messages = projectStructuredAgentSessionMessages(items, [entry], [SEED, rejected]) expect(rows(messages)).toEqual([seedRow, hostRow]) expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 }) const notices = structuredAgentSessionDeliveryNotices( @@ -378,7 +367,7 @@ describe("one row per rejected message, the host's once it records the rejection const resent = restartRejected('held', 'outbox copy', 3) const resend = outboxEntry('resend', 'outbox copy') expect( - rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent], NO_CARDS)) + rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent])) ).toEqual([ seedRow, { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true }, @@ -395,8 +384,7 @@ describe("one row per rejected message, the host's once it records the rejection projectStructuredAgentSessionMessages( [...hostItems, userItem('resend', 4, 'outbox copy')], [resend], - [SEED, resent, recorded], - NO_CARDS + [SEED, resent, recorded] ) ) ).toEqual([ @@ -417,30 +405,47 @@ describe('a rejected message the queue holds', () => { { ...restartRejected('handoff', 'queued text', 3), queuedMessageId: 'card-1' } ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ - seedRow - ]) + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([seedRow]) expect( structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) }) - // A send kept across a restart comes back as a paused card under its own id. - it('is not drawn while a card holds it under its id, and is drawn once that card is gone', () => { + // A send kept across a restart comes back as a paused card; the send itself records that card. + // The card's Edit and Delete remove it, so nothing but that record may hide the send. + it('is never drawn once kept as a card, with the card there, deleted, or edited and sent', () => { const items = [...SEED_ROWS, userItem('kept', 3, 'kept text')] - const submissions = [SEED, restartRejected('kept', 'kept text', 3)] + const kept = { ...restartRejected('kept', 'kept text', 3), keptAsQueuedMessageId: 'kept' } + const submissions = [SEED, kept] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, ['kept']))).toEqual([ - seedRow - ]) + // The card is there, or Delete took it: the transcript reads only the send. + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([seedRow]) expect( - structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set(), [ - 'kept' - ]).size + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([ + // Edit put the text in the composer and the person sent it as a new message. + const edited = submission('edited', 'kept text, edited', 5) + expect( + rows( + projectStructuredAgentSessionMessages( + [...items, userItem('edited', 5, 'kept text, edited')], + [], + [...submissions, edited] + ) + ) + ).toEqual([ seedRow, - { id: agentJournalSubmissionKey('kept'), text: 'kept text', unsent: true } + { id: agentJournalSubmissionKey('edited'), text: 'kept text, edited', unsent: false } + ]) + }) + + it('is drawn as not sent when it was rejected without being kept', () => { + const items = [...SEED_ROWS, userItem('lost', 3, 'lost text')] + const submissions = [SEED, restartRejected('lost', 'lost text', 3)] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('lost'), text: 'lost text', unsent: true } ]) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index bd99e8f8cbb..1108d468739 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -7,8 +7,6 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-jour import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] - function submission(index: number): AgentJournalSubmission { return { clientMessageId: `client-${index}`, @@ -44,9 +42,7 @@ describe('structured agent session message projection', () => { attachments: [], queuedAt: 1 }) - expect( - projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected], NO_CARDS) - ).toEqual([ + expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toEqual([ expect.objectContaining({ id: refusedItem.itemId, blocks: [{ type: 'text', text: 'send 0' }], @@ -68,8 +64,7 @@ describe('structured agent session message projection', () => { const messages = projectStructuredAgentSessionMessages( Array.from({ length: sendCount }, (_, index) => item(index)), outbox, - Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)), - NO_CARDS + Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)) ) expect(messages.filter((message) => message.role === 'user')).toHaveLength(sendCount) @@ -104,8 +99,8 @@ describe('structured agent session message projection', () => { resolvedAt: null } - const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending], NO_CARDS) - const optimistic = projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS) + const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending]) + const optimistic = projectStructuredAgentSessionMessages([], outbox, []) expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) expect(messages.map((message) => message.id)).toEqual([walItem.itemId]) @@ -123,7 +118,7 @@ describe('structured agent session message projection', () => { }) ] - expect(projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS)).toMatchObject([ + expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([ { id: agentJournalSubmissionKey('client-pending'), role: 'user' } ]) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index f527442c2b1..cb620ed72d8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -7,19 +7,17 @@ import { projectStructuredAgentSessionMessages as projectMessages } from '../../ import { projectStructuredQuestionMessages } from './structured-agent-question-projection' /** The desktop's transcript: a message the host accepted and then rejected stays where it was - * sent, as not sent, unless a queued card holds it. */ + * sent, as not sent, unless the queue holds it as a card. */ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[], - /** The queue's live cards; required, since a rejected message a card holds must not draw twice. */ - queuedMessageIds: readonly string[] + submissions: readonly AgentJournalSubmission[] ) { return projectMessages( items, outbox, submissions, - { rejectedInPlace: true, queuedMessageIds }, + { rejectedInPlace: true }, projectStructuredQuestionMessages ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 995d8024769..4dc58abf490 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -159,6 +159,21 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) + // A kept card is held on its own, like a failed one: the host sends the cards behind it. + it('a card behind a kept or a send_failed card is not held by it', () => { + const cards = projectQueuedMessageCards( + [ + draft('failed', 1, { paused: true, pausedReason: 'send_failed' }), + draft('after-failed', 2), + draft('kept', 3, { paused: true, pausedReason: 'kept' }), + draft('behind', 4) + ], + [], + IDLE + ) + expect(cards.map((card) => card.hold)).toEqual(['paused', 'turn', 'paused', 'turn']) + }) + it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts index 3b12ed7b3dc..aa774f7fbf3 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts @@ -13,8 +13,6 @@ import { import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] - function journalItem( itemId: string, sequence: number, @@ -45,7 +43,7 @@ function drawn( submissions: AgentJournalSubmission[] = [] ): string[] { return createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, outbox, submissions, NO_CARDS) + projectStructuredAgentSessionMessages(items, outbox, submissions) ).conversation.map(({ id }) => id) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx index 28ef88564b9..f1cd44528a7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx @@ -9,7 +9,6 @@ import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent afterEach(cleanup) const NONE = new Set() -const NO_CARDS: readonly string[] = [] const EMPTY: never[] = [] function rejected( @@ -65,7 +64,6 @@ it('keeps the same notices across batches in a chat whose only rejection a Stop submissions, journalItems: EMPTY, failedHere: NONE, - queuedMessageIds: NO_CARDS, retry: () => {}, agentName: 'Claude' }), @@ -87,7 +85,6 @@ function renderNotices(submissions: readonly AgentJournalSubmission[]) { submissions: current, journalItems: EMPTY, failedHere: NONE, - queuedMessageIds: NO_CARDS, retry: () => {}, agentName: 'Claude' }), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts index 2c39d306e82..e1a3e42c427 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -19,11 +19,10 @@ export function useStructuredAgentSessionDeliveryNotices(args: { submissions: readonly AgentJournalSubmission[] journalItems: readonly AgentJournalRenderItem[] failedHere: ReadonlySet - queuedMessageIds: readonly string[] retry: (clientMessageId: string) => void agentName: string }): ReadonlyMap { - const { agentName, failedHere, outbox, queuedMessageIds, submissions } = args + const { agentName, failedHere, outbox, submissions } = args // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. const retryRef = useRef(args.retry) useEffect(() => { @@ -54,7 +53,6 @@ export function useStructuredAgentSessionDeliveryNotices(args: { journalRows, startFailures, failedHere, - queuedMessageIds, commandItemIds, loadedItems ), @@ -65,7 +63,6 @@ export function useStructuredAgentSessionDeliveryNotices(args: { journalRows, startFailures, failedHere, - queuedMessageIds, commandItemIds, loadedItems ] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx index 419e6a6cae9..68e388de58c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx @@ -11,7 +11,6 @@ import { useStructuredAgentSessionMessages } from './use-structured-agent-sessio afterEach(cleanup) const EMPTY: never[] = [] -const NO_CARDS: readonly string[] = [] function tool(id: string, sequence: number): AgentJournalRenderItem { return { itemId: id, @@ -26,8 +25,7 @@ it('retains only unchanged item projections across updates, reorder, deletion, a const first = tool('first', 1) const second = tool('second', 2) const { result, rerender } = renderHook( - (items: AgentJournalRenderItem[]) => - useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS), + (items: AgentJournalRenderItem[]) => useStructuredAgentSessionMessages(items, EMPTY, EMPTY), { initialProps: [first, second] } ) const initial = result.current @@ -52,9 +50,7 @@ it('retains only unchanged item projections across updates, reorder, deletion, a [structuredClone(completed)] ]) { rerender(items) - expect(result.current).toEqual( - projectStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS) - ) + expect(result.current).toEqual(projectStructuredAgentSessionMessages(items, EMPTY, EMPTY)) expect(result.current.find((message) => message.id === 'second')).not.toBe(initial[1]) } const replacement = { @@ -66,9 +62,7 @@ it('retains only unchanged item projections across updates, reorder, deletion, a } } rerender([replacement]) - expect(result.current).toEqual( - projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY, NO_CARDS) - ) + expect(result.current).toEqual(projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY)) expect(result.current[0]).not.toBe(initial[0]) }) @@ -97,14 +91,14 @@ it('keeps optimistic sends and their settlement identical to uncached projection }: { items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] - }) => useStructuredAgentSessionMessages(items, [entry], submissions, NO_CARDS), + }) => useStructuredAgentSessionMessages(items, [entry], submissions), { initialProps: { items: [tool('tool', 1)], submissions: [submission] } } ) for (const dispatchState of ['pending', 'unknown', 'accepted'] as const) { const props = { items: [tool('tool', 1)], submissions: [{ ...submission, dispatchState }] } rerender(props) expect(result.current).toEqual( - projectStructuredAgentSessionMessages(props.items, [entry], props.submissions, NO_CARDS) + projectStructuredAgentSessionMessages(props.items, [entry], props.submissions) ) } }) @@ -112,7 +106,7 @@ it('keeps optimistic sends and their settlement identical to uncached projection it('does no transcript projection work on a status-only render', () => { const items = [tool('tool', 1)] const { result, rerender } = renderHook(() => - useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS) + useStructuredAgentSessionMessages(items, EMPTY, EMPTY) ) const initial = result.current rerender() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts index af38cda41df..f965c2d3eaa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts @@ -9,11 +9,10 @@ import { projectStructuredAgentSessionMessages } from './structured-agent-sessio export function useStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[], - queuedMessageIds: readonly string[] + submissions: readonly AgentJournalSubmission[] ) { return useMemo( - () => projectStructuredAgentSessionMessages(items, outbox, submissions, queuedMessageIds), - [items, outbox, submissions, queuedMessageIds] + () => projectStructuredAgentSessionMessages(items, outbox, submissions), + [items, outbox, submissions] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx index fadb4440534..69f802b060d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx @@ -29,9 +29,12 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' -import { readOutbox } from './structured-agent-session-outbox-storage' - -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] @@ -183,6 +186,147 @@ describe('outbox queue delivery selection', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) + // After a quit, the outbox sends an unconfirmed message again under its own id, to a host that + // does not queue, and the host has kept that message as a held card under the same id. Its reply + // is the send's own rejected record; the card is what says the host holds it. + it.each(['reply first', 'card first'] as const)( + 'a send a restart kept as a card leaves the outbox with no Retry and no restore (%s)', + async (order) => { + let answer: (value: unknown) => void = () => undefined + mocks.call.mockImplementation(() => new Promise((resolve) => (answer = resolve))) + const view = renderHook( + (props: { queuedMessageIds: string[] }) => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + composerScopeKey: 'kept-scope', + queueDelivery: { capability: 'unsupported', enabled: true }, + queuedMessageIds: props.queuedMessageIds + }), + { initialProps: { queuedMessageIds: Array.of() } } + ) + expect(view.result.current.send('kept by the host')).toBe(true) + const id = (await sentParams()).envelope.clientOperationId + const rejectedReplay = { + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + clientMessageId: id, + submission: { + clientMessageId: id, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true, + handoverRecorded: true + } + } + } + if (order === 'reply first') { + await act(async () => answer(rejectedReplay)) + await waitFor(() => expect(view.result.current.outbox[0]?.state).toBe('rejected')) + view.rerender({ queuedMessageIds: [id] }) + } else { + view.rerender({ queuedMessageIds: [id] }) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + await act(async () => answer(rejectedReplay)) + } + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readOutbox('session-1')).toEqual([]) + expect(readNativeChatDraftCache('kept-scope')).toBe('') + expect(mocks.call).toHaveBeenCalledTimes(1) + } + ) + + // The card may be sent, edited or deleted on another device before this desktop ever sees it: + // the send's own record says the host kept it, so its local copy leaves with no Retry. + describe('a send the host kept as a card, its card never seen here', () => { + function keptRejection(id: string): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true, + handoverRecorded: true, + keptAsQueuedMessageId: id + } + } + + it('leaves the outbox on the host’s answer', async () => { + mocks.call.mockImplementation(async (_target, _method, params) => ({ + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + clientMessageId: params.envelope.clientOperationId, + submission: keptRejection(params.envelope.clientOperationId) + } + })) + const view = renderHook(() => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + composerScopeKey: 'kept-elsewhere', + queueDelivery: { capability: 'unsupported', enabled: true } + }) + ) + expect(view.result.current.send('kept, then deleted elsewhere')).toBe(true) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readOutbox('session-1')).toEqual([]) + expect(readNativeChatDraftCache('kept-elsewhere')).toBe('') + }) + + it('leaves the outbox when the journal shows it, from a stored not-sent copy', async () => { + writeOutbox('session-1', [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'kept-id', + sessionId: 'session-1', + text: 'kept, then deleted elsewhere', + attachments: [], + queuedAt: 1 + }), + state: 'unconfirmed', + lastAttemptAt: 5 + } + ]) + const view = renderHook(() => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [keptRejection('kept-id')], + queueDelivery: { capability: 'unsupported', enabled: true } + }) + ) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readOutbox('session-1')).toEqual([]) + expect(mocks.call).not.toHaveBeenCalled() + }) + }) + it("Stop's local step never restores a queued send already in flight — its answer settles it", async () => { // The send is on its way; the Stop lands behind it, so the host may already hold // it as a paused card. Restoring it locally too would double the text. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx index bfebc877b2c..5e736e1dcfe 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx @@ -31,8 +31,6 @@ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session- const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] -const NO_CARDS: readonly string[] = [] - afterEach(cleanup) beforeEach(() => { @@ -91,7 +89,7 @@ it('draws a send its reply rejected in place once, and leaves the composer empty body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'steer this way' }] } } const users = (outbox: typeof result.current.outbox) => - projectStructuredAgentSessionMessages([hostItem], outbox, [submission], NO_CARDS) + projectStructuredAgentSessionMessages([hostItem], outbox, [submission]) .filter((message) => message.role === 'user') .map((message) => ({ id: message.id, unsent: message.unsent })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index bd445b6e2ce..748487986a3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -26,6 +26,9 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] + /** The host queues sends. Without it a card can still show — a message the host kept unsent — + * but queueing settings and the steer chord would do nothing. */ + queueCapable: boolean /** Why the whole queue sends nothing on its own; null when it drains. Shown only with cards. * A string reason: a newer host may name one this build does not know. */ pause: { reason: string } | null @@ -183,5 +186,15 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, pause, resume, resuming, steer, remove, edit, steerNewest } + return { + cards, + queueCapable: enabled, + pause, + resume, + resuming, + steer, + remove, + edit, + steerNewest + } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx index b37350cb39d..025aba64fb8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -// The session controller hands the queue's live cards to the transcript: a rejected message one of -// them holds under its own id is drawn as that card, never also as a not-sent row. +// A send the host kept as a card is drawn as that card, never also as a not-sent row, and its +// card's Edit or Delete brings no row back: the transcript reads the send's own record. import { renderHook } from '@testing-library/react' import { beforeEach, expect, it, vi } from 'vitest' @@ -15,6 +15,7 @@ import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' let queuedMessages: AgentSessionQueuedMessage[] = [] +let submissions: AgentJournalSubmission[] = [] const KEPT_BODY: AgentJournalMessageItem = { kind: 'message', @@ -52,7 +53,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items: [KEPT_ITEM], - submissions: [KEPT_SUBMISSION], + submissions, status: 'ready', error: null, hasOlder: false, @@ -104,13 +105,19 @@ function keptRows(): { id: string; unsent?: true }[] { beforeEach(() => { queuedMessages = [] + submissions = [{ ...KEPT_SUBMISSION, keptAsQueuedMessageId: 'kept' }] }) -it('draws no row for a rejected message while a card holds it under its id', () => { +it('draws no row for a kept send while its card is there', () => { queuedMessages = [card('kept')] expect(keptRows()).toEqual([]) }) -it('draws it as not sent once no card holds it', () => { +it('draws no row for a kept send once its card was edited or deleted', () => { + expect(keptRows()).toEqual([]) +}) + +it('draws a rejected send the host did not keep as not sent', () => { + submissions = [KEPT_SUBMISSION] expect(keptRows()).toEqual([{ id: KEPT_ITEM.itemId, unsent: true }]) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 3768a56a5b8..ef5a3db46c1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -114,8 +114,7 @@ export function useStructuredAgentSession(args: { mutate, ...(launch ? { launch } : {}) }) - // Only a capable host may see `delivery` or the queuedMessage RPCs; against - // anything older this client must look exactly like today's. + // Only a capable host may see `delivery`; a card any host publishes shows, with its actions. const queueCapability = useStructuredAgentSessionHostQueuesMessagesState(target) const queueCapable = queueCapability === 'supported' const queuedMessageIds = useMemo( @@ -204,8 +203,7 @@ export function useStructuredAgentSession(args: { const messages = useStructuredAgentSessionMessages( transcriptItems, transcriptOutbox, - transportState.submissions, - queuedMessageIds + transportState.submissions ) const queuedController = useStructuredAgentSessionQueuedMessages({ enabled: queueCapable && transportState.fence !== null, @@ -252,8 +250,6 @@ export function useStructuredAgentSession(args: { failedHere: outboxController.failedHere, /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, - /** The host's queued cards, which hold their own rejected hand-offs. */ - queuedMessageIds, // A message typed during a command queues behind it on the host. send: (...input: Parameters) => // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f9071f05610..7ff593c472d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18312,6 +18312,7 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", + "pausedKept": "Not sent yet — press Send to send it.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", "queuePausedRestarted": "Queue paused because Orca restarted", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0da94aca473..9708bdc6ba1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17943,6 +17943,7 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", + "pausedKept": "Aún no se ha enviado. Pulsa Enviar para enviarlo.", "paused": "En pausa", "queuePausedStopped": "Cola en pausa porque interrumpiste", "queuePausedRestarted": "Cola en pausa porque Orca se reinició", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 1213c89d445..3bc1a96946e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18160,6 +18160,7 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", + "pausedKept": "Pas encore envoyé. Appuyez sur Envoyer pour l'envoyer.", "paused": "En pause", "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 066c752ceb8..fda99065b37 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18096,6 +18096,7 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", + "pausedKept": "まだ送信されていません。「送信」を押すと送信されます。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a043ec36ea9..e06aba01d15 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18096,6 +18096,7 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", + "pausedKept": "아직 보내지 않았습니다. 보내기를 눌러 보내세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3af1cb962ed..36efa599608 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18061,6 +18061,7 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", + "pausedKept": "尚未发送。点按发送即可发送。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index e083eadb57a..0de74130a3e 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -102,8 +102,8 @@ export function useStructuredAgentSessionHostRecoversRewindOnSend( return useStructuredAgentSessionHostCapability(target, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY) } -/** Whether the host holds mid-turn sends as drafts: only then may a client send `delivery` - * or call the queuedMessage RPCs. */ +/** Whether the host holds mid-turn sends as drafts: only then may a client send `delivery`. The + * published cards and their queuedMessage actions are not gated on it. */ export function useStructuredAgentSessionHostQueuesMessages(target: RuntimeClientTarget): boolean { return useStructuredAgentSessionHostQueuesMessagesState(target) === 'supported' } diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 594d6a04b11..10dac12611b 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -3,9 +3,9 @@ import { AgentJournalItemBodySchema, isAdmissibleAgentJournalItemBody, isAdmissibleAgentJournalMessageBody, - isAdmissibleAgentJournalRenderItem, - isAdmissibleAgentJournalSubmission + isAdmissibleAgentJournalRenderItem } from './agent-session-journal-schemas' +import { isAdmissibleAgentJournalSubmission } from './agent-session-journal-submission-schema' import type { AgentJournalItemBody, AgentJournalRenderItem, diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index a2070706b9e..89118307dd6 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -28,14 +28,12 @@ import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' -import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union' import type { AgentJournalItemBody, AgentJournalMessageItem, AgentJournalResolution, - AgentJournalRenderItem, - AgentJournalSubmission + AgentJournalRenderItem } from './agent-session-journal-types' const BoundedPayload = z.object({ @@ -310,25 +308,6 @@ export const AgentJournalRenderItemSchema = z.object({ ...AgentJournalProducerLinkageFields }) -export const AgentJournalSubmissionSchema = z.object({ - clientMessageId: z.string().min(1), - fence: z.number().int(), - payloadFingerprint: z.string(), - dispatchState: z.string().min(1), - providerItemId: z.string().nullable(), - reason: z.string().nullable(), - submittedAt: z.number(), - resolvedAt: z.number().nullable(), - submittedSequence: z.number().int().optional(), - answeredInTurn: AgentJournalAnsweredTurnSchema.optional(), - recovered: z.literal(true).optional(), - handoverRecorded: z.literal(true).optional(), - handedOverAt: z.number().optional(), - rejection: AgentSessionFailureFactSchema.optional(), - // Listed, or the parse strips it: this schema drops unknown keys. - queuedMessageId: z.string().min(1).optional() -}) - export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { return Resolution.safeParse(value).success } @@ -350,12 +329,6 @@ export function isAdmissibleAgentJournalRenderItem( return AgentJournalRenderItemSchema.safeParse(value).success } -export function isAdmissibleAgentJournalSubmission( - value: unknown -): value is AgentJournalSubmission { - return AgentJournalSubmissionSchema.safeParse(value).success -} - /** Compile-time proof that every canonical value is admissible, so replay can * never reject a row a writer in this build produced. The schemas are * deliberately wider on open string fields, so only this direction holds. */ @@ -363,8 +336,5 @@ type Admits = T export type CanonicalJournalTypesAreAdmissible = [ Admits ? true : false>, Admits ? true : false>, - Admits< - AgentJournalRenderItem extends z.input ? true : false - >, - Admits ? true : false> + Admits ? true : false> ] diff --git a/src/shared/agent-session-journal-submission-schema.test.ts b/src/shared/agent-session-journal-submission-schema.test.ts new file mode 100644 index 00000000000..56414411c77 --- /dev/null +++ b/src/shared/agent-session-journal-submission-schema.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + AgentJournalSubmissionSchema, + isAdmissibleAgentJournalSubmission +} from './agent-session-journal-submission-schema' + +// Persisted and published: a reader that keeps the parsed value must not lose either fact. +it('keeps a kept send’s card id and its source through a parse', () => { + const kept: AgentJournalSubmission = { + clientMessageId: 'client-kept', + fence: 2, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true, + source: { kind: 'user' }, + keptAsQueuedMessageId: 'client-kept' + } + expect(isAdmissibleAgentJournalSubmission(kept)).toBe(true) + expect(AgentJournalSubmissionSchema.parse(kept)).toEqual(kept) + expect(AgentJournalSubmissionSchema.parse(JSON.parse(JSON.stringify(kept)))).toEqual(kept) +}) + +it('refuses an empty card id rather than reading it as kept', () => { + expect( + isAdmissibleAgentJournalSubmission({ + clientMessageId: 'client-kept', + fence: 2, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + keptAsQueuedMessageId: '' + }) + ).toBe(false) +}) diff --git a/src/shared/agent-session-journal-submission-schema.ts b/src/shared/agent-session-journal-submission-schema.ts new file mode 100644 index 00000000000..0eeffb13a17 --- /dev/null +++ b/src/shared/agent-session-journal-submission-schema.ts @@ -0,0 +1,42 @@ +// The deep validator for a journal submission (`AgentJournalSubmission`), the row a sent message's +// dispatch state lives on. Open string fields stay type-checked, never enum-checked, as in +// `agent-session-journal-schemas.ts`. + +import { z } from 'zod' +import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' +import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' +import type { AgentJournalSubmission } from './agent-session-journal-types' + +// Every persisted field is listed, or a parse strips it: this schema drops unknown keys. +export const AgentJournalSubmissionSchema = z.object({ + clientMessageId: z.string().min(1), + fence: z.number().int(), + payloadFingerprint: z.string(), + dispatchState: z.string().min(1), + providerItemId: z.string().nullable(), + reason: z.string().nullable(), + submittedAt: z.number(), + resolvedAt: z.number().nullable(), + submittedSequence: z.number().int().optional(), + answeredInTurn: AgentJournalAnsweredTurnSchema.optional(), + recovered: z.literal(true).optional(), + handoverRecorded: z.literal(true).optional(), + handedOverAt: z.number().optional(), + rejection: AgentSessionFailureFactSchema.optional(), + queuedMessageId: z.string().min(1).optional(), + // The kind only; an object's other keys (a sender) are stripped, never published. + source: z.object({ kind: z.string() }).optional(), + keptAsQueuedMessageId: z.string().min(1).optional() +}) + +export function isAdmissibleAgentJournalSubmission( + value: unknown +): value is AgentJournalSubmission { + return AgentJournalSubmissionSchema.safeParse(value).success +} + +/** Compile-time proof that every submission this build writes is admissible. */ +type Admits = T +export type CanonicalJournalSubmissionIsAdmissible = Admits< + AgentJournalSubmission extends z.input ? true : false +> diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index d2638fd9182..ff5d53aca76 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -478,6 +478,15 @@ export type AgentJournalSubmission = { * A person's turn is what ends a Stop's queue pause. The snapshot still carries it; no released * client reads it. */ origin?: 'client' | 'host' + /** Who it is from: the kind of its `AgentSessionMessageSource` ('user' or 'agent'), so a restart + * or a close keeps only a person's unsent send as a card. Only the kind: the senders stay on the + * card, host-only, and publishing them here would need a strip. A newer build's kind is kept as + * written, never read as absent. Absent when its sender named none (a dispatch preamble, a restart continuation). */ + source?: { kind: string } + /** On a rejected send the host kept as a card: that card's message id. The text lives on the + * card, so no surface draws this send, before or after the card is sent, edited or deleted. + * Recorded in the rejection's own transaction (`journal-unsent-send-hold.ts`). */ + keptAsQueuedMessageId?: string } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index f7659c95873..6a2829476d6 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -7,7 +7,13 @@ import type { AgentJournalMessageItem } from './agent-session-journal-types' /** The draft could not be converted into a send; an explicit Send retries it. */ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const -export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED +/** A person's message the host accepted and never handed over before a restart or a close of the + * chat. It waits for the person's own Send; the cards behind it still send, as past a failed one. */ +export const QUEUED_MESSAGE_PAUSED_KEPT = 'kept' as const + +export type AgentSessionQueuedMessagePausedReason = + | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED + | typeof QUEUED_MESSAGE_PAUSED_KEPT /** The whole queue is paused and sends nothing on its own: 'stopped' — the user * interrupted ("Queue paused because you interrupted") — 'cleared' — a /clear @@ -30,11 +36,11 @@ export type AgentSessionQueuedMessage = { position: number body: AgentJournalMessageItem state: 'waiting' | 'returned' - /** This one card is held, whatever the queue's pause: its conversion failed. */ + /** This one card is held, whatever the queue's pause: its conversion failed, or the host kept it. */ paused?: true /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't - * send"; only an explicit Send releases it). A client must treat an unknown - * marker as a plain hold, so a newer host can add one. The queue-level + * send") or 'kept' (accepted, never sent); only an explicit Send releases either. A client + * must treat an unknown marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 2b34d58bb05..87f59b2f10f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -215,14 +215,16 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = 'agent-session.send-answers-proof.v1' as const // Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a -// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON -// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer -// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. +// capable client can render the `queued` result arm. It gates `delivery: 'queue-if-active'` and +// the client's queueing setting and chord, never the published draft list or its card actions: a +// host without it still publishes a message it kept unsent across a restart or a close, and both +// clients show that card and call its actions. DARK ON PURPOSE — not in RUNTIME_CAPABILITIES: +// advertising still requires the integrated Codex steer matrix (#21062) in the shipped host. // v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and // never compares a draft id with a submission id. It also publishes the queue's pause once, as // `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next -// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant -// gates the rollout. +// turn; a card carries a hold of its own when its conversion failed (`send_failed`) or the host +// kept it unsent (`kept`). The host mechanism lands first; the constant gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts new file mode 100644 index 00000000000..c0829aa02b6 --- /dev/null +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -0,0 +1,85 @@ +// A send the host kept as a card is drawn only as that card: the rule rides on the submission's +// own fact, never on the card still being there. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { createStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const KEPT_ID = 'client-kept' +// The desktop draws a rejected send in place, as not sent, unless the host kept it as a card. +const DESKTOP = { rejectedInPlace: true } + +function rejected(fields: Partial = {}): AgentJournalSubmission { + return { + clientMessageId: KEPT_ID, + fence: 1, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + ...fields + } +} + +function userItem(id: string, text: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } +} + +/** The sending desktop's own copy, still marked not sent. */ +const lingeringCopy = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: KEPT_ID, + sessionId: 'session-1', + text: 'the kept words', + attachments: [], + queuedAt: 1 + }), + state: 'rejected' as const +} + +describe('a send kept as a card', () => { + it('shows nothing of the original send, even beside its own local copy', () => { + const kept = rejected({ keptAsQueuedMessageId: KEPT_ID }) + const items = [userItem(KEPT_ID, 'the kept words', 1)] + expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [kept], DESKTOP)).toEqual( + [] + ) + // A rejection with no card keeps its local copy, marked not sent. + expect( + projectStructuredAgentSessionMessages(items, [lingeringCopy], [rejected()], DESKTOP) + ).toEqual([expect.objectContaining({ id: agentJournalSubmissionKey(KEPT_ID), unsent: true })]) + }) + + // Edit is the card's text in the composer, then the card's Delete, then a new send. + it('after an Edit sent again, shows exactly the new send', () => { + const kept = rejected({ keptAsQueuedMessageId: KEPT_ID }) + const edited: AgentJournalSubmission = { + ...rejected(), + clientMessageId: 'client-edited', + dispatchState: 'accepted', + providerItemId: 'provider-edited', + reason: null + } + delete edited.rejection + const shown = projectStructuredAgentSessionMessages( + [userItem(KEPT_ID, 'the kept words', 1), userItem('client-edited', 'the edited words', 2)], + [lingeringCopy], + [kept, edited], + DESKTOP + ) + expect(shown).toHaveLength(1) + expect(shown[0]).toMatchObject({ blocks: [{ text: 'the edited words' }] }) + expect(shown[0]).not.toHaveProperty('unsent') + }) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 57a2f6fe15d..6d656ea560d 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -13,8 +13,6 @@ export type StructuredAgentSessionMessageProjectionOptions = { /** Draw a message the host accepted and then rejected where the host recorded it, as not sent. * Off for a client that hands such a message back to its composer instead. */ rejectedInPlace: boolean - /** The queue's live cards: a rejected message one of them holds is drawn there, not here. */ - queuedMessageIds?: readonly string[] } /** The loaded items that are a conversation command such as `/compact`, by item id. */ @@ -31,15 +29,13 @@ export function structuredAgentSessionCommandItemIds( /** * The rejected submissions the host's history shows in place as not sent, by item id; `submissions` * in submission order, as the client keeps them. A withdrawn one went back to its sender, one the - * queue holds (a draft's hand-off, or a card under its id) is drawn as its card, and a command such - * as `/compact` has its rejection reported as its own reply. + * queue holds (a draft's hand-off, or a send kept as a card) is drawn as its card, and a command + * such as `/compact` has its rejection reported as its own reply. */ export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], - queuedMessageIds: readonly string[], commandItemIds: ReadonlySet ): Set { - const cards = new Set(queuedMessageIds) // Each body's copies, as positions in submission order. A withdrawn one is hidden too, so it // supersedes nothing. const copies = new Map() @@ -61,7 +57,8 @@ export function structuredAgentSessionRejectedShownInPlace( submission.dispatchState !== 'rejected' || dispatchWasWithdrawn(submission) || submission.queuedMessageId !== undefined || - cards.has(submission.clientMessageId) || + // Recorded on the send itself, so an Edit or Delete of its card brings no row back. + submission.keptAsQueuedMessageId !== undefined || commandItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) || // Collapses resends of a rejected message: past Retries resent it under a new id, and the // host re-delivers its own messages under new ids. Only a later copy sent once the rejection @@ -95,7 +92,6 @@ export function projectStructuredAgentSessionMessages( const inPlace = options.rejectedInPlace ? structuredAgentSessionRejectedShownInPlace( submissions, - options.queuedMessageIds ?? [], structuredAgentSessionCommandItemIds(items) ) : new Set() diff --git a/src/shared/structured-agent-session-outbox-reconcile.test.ts b/src/shared/structured-agent-session-outbox-reconcile.test.ts index 0336bd649e4..64417e6c3db 100644 --- a/src/shared/structured-agent-session-outbox-reconcile.test.ts +++ b/src/shared/structured-agent-session-outbox-reconcile.test.ts @@ -98,3 +98,12 @@ it('returns the list it was given when nothing changes', () => { entries ) }) + +// The card carries the text from here; the copy would draw a "Not sent" row the transcript hides. +it('drops the copy of a send the host kept as a card, with its row not yet loaded', () => { + const kept = { ...submission('kept', 'rejected'), keptAsQueuedMessageId: 'kept' } + expect(reconcileStructuredAgentSessionOutbox([entry('kept')], [kept], [])).toEqual([]) + expect( + reconcileStructuredAgentSessionOutbox([entry('kept')], [submission('kept', 'rejected')], []) + ).toEqual([expect.objectContaining({ clientMessageId: 'kept', state: 'rejected' })]) +}) diff --git a/src/shared/structured-agent-session-outbox-reconcile.ts b/src/shared/structured-agent-session-outbox-reconcile.ts index 9fa699eece1..abcd34d9d67 100644 --- a/src/shared/structured-agent-session-outbox-reconcile.ts +++ b/src/shared/structured-agent-session-outbox-reconcile.ts @@ -27,6 +27,13 @@ export function reconcileStructuredAgentSessionOutbox( if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { return [] } + // The host kept it as a card, which carries the text from here, edited or deleted included. + if ( + submission?.dispatchState === 'rejected' && + submission.keptAsQueuedMessageId !== undefined + ) { + return [] + } if (submission?.dispatchState === 'rejected') { // Its row draws it once loaded; until then the entry does, as the host recorded it. An older // host leaves that row where it was sent, which may be outside the loaded window. diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 77e1030d4b2..3dad3dc4dc0 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -274,6 +274,14 @@ export function disposeStructuredAgentSessionSendResult( error: null } } + // The host kept it as a card, first reply or replay: the card owns the text, so the entry + // leaves as the reconcile drops it, with no Retry that could send words the card's Delete took. + if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) { + return { + entries: dropEntry(input), + error: null + } + } // Recorded, so the journal's row shows it once it arrives; until then the entry draws it, saying // why and offering no Retry. if (submission.dispatchState === 'rejected') { diff --git a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts new file mode 100644 index 00000000000..86eed2b8271 --- /dev/null +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -0,0 +1,227 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity, + AgentSessionJournalProviderHandle +} from '../../../src/shared/agent-session-journal-types' +import { claudeProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { USER_MESSAGE_SOURCE } from '../../../src/shared/agent-session-message-source' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../src/shared/agent-session-queued-message-wire' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { holdUnsentSends } from '../../../src/main/native-chat/agent-session-journal/journal-unsent-send-hold' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A kept send is an ordinary waiting card held by a new value in its existing `hold_reason` +// column, placed ahead of the queue: no new state or column. A build with the queue but without +// this change reads an unknown hold as a plain one: it must list the card first and never send it, +// even after a person's turn ends a restart's pause, and must still settle a send this build's quit +// left queued. One exception lives in that build's own code: its /clear carries every card over +// without its hold, so the replacement's next turn sends a carried kept card. The main commit this change branched from, which has the queue; move it to the +// newest release that has the queue and predates this change. A baseline holding this change tests +// no downgrade. +const BASELINE_REF = '5a56636f6679071d6ec68b851ef7932cd3222560' +const JOURNAL = 'src/main/native-chat/agent-session-journal' +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-kept-downgrade', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) +} + +/** The identity as builds before the neutral provider handle took it. */ +type OlderJournalIdentity = Omit & { + providerHandle: AgentSessionJournalProviderHandle +} + +const OLDER_IDENTITY: OlderJournalIdentity = { + ...IDENTITY, + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +type OlderCard = { + messageId: string + state: string + position: number + hostInstance: string + holdReason: string | null +} + +type OlderJournal = { + queuedMessages: { + list: () => readonly OlderCard[] + pauses: (hostInstance: string) => { reason: string }[] + adopt: (hostInstance: string) => Promise + } + submission: (id: string) => { dispatchState: string; reason: string | null } | undefined + wroteBeforeOpen: (sequence: number | undefined) => boolean + rejectQueuedSubmissions: ( + fence: number, + rejection: typeof HOST_RESTARTED, + which: (submission: { acceptedSequence?: number }) => boolean + ) => Promise + repair: { malformedRows: number } +} + +type OlderOpener = { + open: (options: { + identity: OlderJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise +} + +type OlderNextSendable = ( + pauses: readonly { reason: string }[], + cards: readonly OlderCard[] +) => OlderCard | null + +/** The pinned build's drain pick (`nextSendableQueuedCard`): the card it would send next. */ +async function olderNextSendable(): Promise { + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const pause = await importReleaseCheckoutModule(checkout, `${JOURNAL}/queued-message-pause.ts`) + const next = pause.nextSendableQueuedCard + if (typeof next !== 'function') { + throw new Error('the pinned build exports no nextSendableQueuedCard') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned build's own drain pick, called with that build's own pauses and cards. + return next as OlderNextSendable +} + +async function olderOpener(): Promise { + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const create = support.createTrackedJournalOpener + if (typeof create !== 'function') { + throw new Error('the pinned build exports no createTrackedJournalOpener') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned build's own test opener; each member read here is named in `OlderOpener`, and a changed one fails the test. + return create() as OlderOpener +} + +async function acceptPersonSend( + journal: Awaited['open']>>, + id: string +): Promise { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: message(id), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) +} + +test('an older build lists a kept card first and never sends it, even after a person’s turn', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-kept-card-downgrade-')) + const journals = createTrackedJournalOpener() + try { + const earlier = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await earlier.queuedMessages.insert({ + messageId: 'queued-card', + body: message('queued-card'), + fingerprint: 'fp-queued-card', + hostInstance: 'host-a', + source: USER_MESSAGE_SOURCE + }) + await acceptPersonSend(earlier, 'kept') + await journals.closeAll() + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await holdUnsentSends(reopened, { + fence: 0, + hostInstance: 'host-b', + hold: { cause: 'hostRestarted' } + }) + expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ + 'kept', + 'queued-card' + ]) + await journals.closeAll() + + const older = await olderOpener() + const nextSendable = await olderNextSendable() + try { + const downgraded = await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) + const cards = () => + downgraded.queuedMessages.list().map(({ messageId, state, holdReason }) => ({ + messageId, + state, + holdReason + })) + expect(cards()).toEqual([ + { messageId: 'kept', state: 'waiting', holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, + { messageId: 'queued-card', state: 'waiting', holdReason: null } + ]) + expect(downgraded.queuedMessages.list()[0]!.position).toBeLessThan(1) + expect(downgraded.submission('kept')).toMatchObject({ dispatchState: 'rejected' }) + // A person's turn there adopts every card into its process, which ends the restart's + // pause; the kept card's hold survives it, so its drain never picks it. That build skips a + // held card as it skips a failed send's, so the card behind it is the one it sends. + expect(await downgraded.queuedMessages.adopt('host-c')).toBe(true) + expect(cards()[0]).toMatchObject({ + messageId: 'kept', + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + expect(downgraded.queuedMessages.pauses('host-c')).toEqual([]) + expect( + nextSendable(downgraded.queuedMessages.pauses('host-c'), downgraded.queuedMessages.list()) + ?.messageId + ).toBe('queued-card') + } finally { + await older.closeAll() + } + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000) + +test("an older build reads the send this build's quit left queued, its source included, and settles it", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-kept-leftover-downgrade-')) + const journals = createTrackedJournalOpener() + try { + const quitting = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await acceptPersonSend(quitting, 'left-queued') + await journals.closeAll() + + const older = await olderOpener() + try { + const downgraded = await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) + expect(downgraded.repair).toEqual({ malformedRows: 0 }) + expect(downgraded.submission('left-queued')).toMatchObject({ dispatchState: 'pending' }) + // Its delivery loop's first step, as that build runs it: rejected, never handed over. + expect( + await downgraded.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + downgraded.wroteBeforeOpen(submission.acceptedSequence) + ) + ).toEqual(['left-queued']) + expect(downgraded.submission('left-queued')).toMatchObject({ + dispatchState: 'rejected', + reason: HOST_RESTARTED.reason + }) + } finally { + await older.closeAll() + } + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000)