From 48479266b91f08d0c8b36567c974677b6057cf66 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:34:36 -0700 Subject: [PATCH 01/22] fix(native-chat): keep a message accepted before a quit or crash as a held card A send the host accepted while the agent was still starting, and never handed over, was rejected unseen at quit or at the next open after a crash. The next open now keeps a person's message (typed, or a launch's first prompt) as a waiting card at the head of the queue, held until Resume, Send now, Edit or Delete; quit no longer rejects it. Each submission records its source so a restart knows which leftovers to keep. A direct send's replay answers from its own record, never the queued arm. Cards shown without the queue capability hide Turn off queueing and the steer chord. --- .../journal-leftover-send-hold.test.ts | 290 ++++++++++++++ .../journal-leftover-send-hold.ts | 198 +++++++++ .../journal-queued-messages.ts | 32 +- .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 4 + .../journal-store-contracts.ts | 3 + .../agent-session-journal/journal-store.ts | 8 +- .../journal-submission-fold.ts | 12 +- .../queued-message-holds.ts | 12 +- .../queued-message-pause.test.ts | 41 ++ .../queued-message-pause.ts | 16 +- .../queued-message-positions.ts | 31 ++ .../queued-message-retention.ts | 2 +- .../queued-message-table.ts | 4 +- ...-agent-session-accept-then-deliver.test.ts | 21 +- ...uctured-agent-session-conversation-open.ts | 20 +- .../structured-agent-session-delivery-loop.ts | 20 +- .../structured-agent-session-host-lifetime.ts | 8 +- ...structured-agent-session-host-mutations.ts | 2 + .../structured-agent-session-host-teardown.ts | 25 +- ...d-agent-session-leftover-send-hold.test.ts | 377 ++++++++++++++++++ ...structured-agent-session-mutation-plans.ts | 19 +- ...gent-session-provider-child-record.test.ts | 43 +- ...session-queued-message-rig.test-fixture.ts | 7 + ...tructured-agent-session-queued-messages.ts | 1 + ...ructured-agent-session-queued-mutations.ts | 8 +- .../structured-agent-session-queued-pause.ts | 20 +- ...ed-agent-session-restart-ownership.test.ts | 3 + ...red-agent-session-restart-resume-wiring.ts | 6 +- ...ent-session-restore-without-import.test.ts | 54 ++- .../structured-agent-session-turns.ts | 4 +- ...tured-agent-session-working-at-teardown.ts | 2 +- .../structured-mailbox-pointer-host.test.ts | 2 + .../structured-mailbox-pointer-host.ts | 3 +- .../agent-launch-structured-prompt.test.ts | 2 + .../methods/agent-launch-structured-prompt.ts | 8 +- .../NativeChatQueuedMessageCard.tsx | 17 +- .../NativeChatQueuedMessageList.test.tsx | 32 +- .../NativeChatQueuedMessageList.tsx | 8 +- ...ent-session-outbox.queue-delivery.test.tsx | 61 +++ ...tructured-agent-session-queued-messages.ts | 15 +- src/shared/agent-session-journal-types.ts | 14 + .../leftover-card-downgrade.unit.test.ts | 170 ++++++++ 43 files changed, 1525 insertions(+), 105 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-leftover-send-hold.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-leftover-send-hold.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-positions.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-leftover-send-hold.test.ts create mode 100644 tests/e2e/cross-version-wire/leftover-card-downgrade.unit.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.test.ts new file mode 100644 index 000000000000..23417262fd36 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.test.ts @@ -0,0 +1,290 @@ +// Which sends an earlier host process left queued become 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, + AgentJournalSubmissionSource, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' +import { holdJournalLeftoverSends, leftoverSendHeldAsCard } from './journal-leftover-send-hold' +import type { AgentSessionJournal } from './journal-store' +import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from './queued-message-pause' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-held', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) + +let root: string +let clock = 1_000 +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-1' + }) +} + +async function accept( + journal: AgentSessionJournal, + id: string, + fields: { + body?: AgentJournalMessageItem + origin?: 'client' | 'host' + source?: AgentJournalSubmissionSource + } = {} +): 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() +} + +function cardOrder(journal: AgentSessionJournal): string[] { + return journal.queuedMessages + .list() + .filter((card) => card.state === 'waiting') + .map((card) => card.messageId) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-leftover-hold-')) +}) + +afterEach(async () => { + await journals.closeAll() + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +describe('which leftover sends 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: 'person' }) + await accept(earlier, 'launch', { origin: 'host', source: 'launch' }) + await accept(earlier, 'legacy-client', { origin: 'client' }) + }) + await holdJournalLeftoverSends(journal, 0) + + expect(cardOrder(journal)).toEqual(['person', 'launch', 'legacy-client']) + for (const id of ['person', 'launch', 'legacy-client']) { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(journal.queuedMessages.get(id)).toMatchObject({ + state: 'waiting', + hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + body: message(`text of ${id}`), + fingerprint: fingerprint(message(`text of ${id}`)), + carriedFrom: null, + queuedAt: { epoch: 'epoch-1', sequence: journal.submission(id)?.acceptedSequence } + }) + } + }) + + it('rejects mail, 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' }) + await accept(earlier, 'continuation', { origin: 'host', source: 'continuation' }) + await accept(earlier, 'compact', { + origin: 'client', + source: 'person', + body: structuredAgentSessionCompactBody() + }) + await accept(earlier, 'image', { origin: 'client', source: 'person', body: image }) + await accept(earlier, 'legacy-host', { origin: 'host' }) + await accept(earlier, 'no-origin') + }) + await holdJournalLeftoverSends(journal, 0) + + expect(journal.queuedMessages.list()).toEqual([]) + for (const id of ['mail', 'continuation', 'compact', 'image', 'legacy-host', 'no-origin']) { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + } + }) + + it('a card’s own hand-off returns its card, and makes no second one', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'card', + body: message('card text'), + fingerprint: fingerprint(message('card text')), + hostInstance: 'proc-1' + }) + await earlier.appendSubmission( + { + clientMessageId: 'handoff', + payloadFingerprint: fingerprint(message('card text')), + body: message('card text'), + fence: 0, + handoverRecorded: true, + origin: 'host', + source: 'queue' + }, + { messageId: 'card', expect: 'waiting', settledByOp: null } + ) + }) + await holdJournalLeftoverSends(journal, 0) + + expect(journal.queuedMessages.list()).toHaveLength(1) + expect(journal.queuedMessages.get('card')).toMatchObject({ state: 'waiting', consumedAs: null }) + 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: 'person' }) + await holdJournalLeftoverSends(journal, 0) + 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(leftoverSendHeldAsCard({ source: 'person' }, body)).toBe(body) + expect(leftoverSendHeldAsCard({ source: 'queue' }, body)).toBeNull() + expect(leftoverSendHeldAsCard({ origin: 'client', queuedMessageId: 'card' }, body)).toBeNull() + expect(leftoverSendHeldAsCard({ origin: 'client' }, null)).toBeNull() + }) +}) + +describe('where kept sends go in the queue', () => { + it('in acceptance order, a returned hand-off among them, all ahead of the cards already waiting', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'H', + body: message('H'), + fingerprint: fingerprint(message('H')), + hostInstance: 'proc-1' + }) + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1' + }) + await accept(earlier, 'A', { origin: 'client', source: 'person' }) + await earlier.appendSubmission( + { + clientMessageId: 'H-handoff', + payloadFingerprint: fingerprint(message('H')), + body: message('H'), + fence: 0, + handoverRecorded: true, + origin: 'host', + source: 'queue' + }, + { messageId: 'H', expect: 'waiting', settledByOp: null } + ) + await accept(earlier, 'B', { origin: 'client', source: 'person' }) + }) + await holdJournalLeftoverSends(journal, 0) + + expect(cardOrder(journal)).toEqual(['A', 'H', 'B', 'C']) + }) + + 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' + }) + await accept(earlier, 'A', { origin: 'client', source: 'person' }) + await accept(earlier, 'B', { origin: 'client', source: 'person' }) + }) + // 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: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + queuedAt: { + epoch: 'epoch-1', + sequence: interrupted.submission('A')!.acceptedSequence! + }, + position: 5 + }, + positions: [] + }) + } + ) + await interrupted.close() + const journal = await open() + await holdJournalLeftoverSends(journal, 0) + + expect(cardOrder(journal)).toEqual(['A', 'B', 'C']) + }) +}) + +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: 'launch' }) + await accept(journal, 'without', { origin: 'client' }) + const folded: AgentJournalSubmission | undefined = journal.submission('with-source') + expect(folded?.source).toBe('launch') + expect(journal.submission('without')).not.toHaveProperty('source') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.ts new file mode 100644 index 000000000000..93214791bcf4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.ts @@ -0,0 +1,198 @@ +// What becomes of a send an earlier host process accepted and never handed over: it was quit or +// crashed first, so the agent provably never got it. A person's message (typed, or a launch's first +// prompt) is kept as a waiting card at the head of the queue, held until someone acts on the queue +// (`QUEUED_MESSAGE_HELD_ACROSS_RESTART`). Everything else is rejected as before, because something +// else re-derives it or the person re-runs it. Either way the submission itself is 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 { 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 { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from './queued-message-pause' +import type { QueuedMessagePositionMove } from './queued-message-positions' + +/** The body a leftover send is kept with, or null when it is rejected instead: + * - a card's own hand-off: its rejection already sends the card back to waiting; + * - `/compact` and other commands: a command in flight is not resumed, the person re-runs it; + * - an image: cards are text-only; + * - orchestration mail (the mailbox re-sends it), a restart continuation (its offer is + * re-derived), and a row with no source that is not a person's (it could be either). */ +export function leftoverSendHeldAsCard( + 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 + const persons = + source === 'person' || + source === 'launch' || + // A build before `source` was recorded: `client` was only ever a person's send. + (source === undefined && submission.origin === 'client') + return persons ? body : null +} + +type Placed = { sequence: number; move: QueuedMessagePositionMove } + +/** + * Settles every send an earlier host process left queued. Each is rejected as `hostRestarted` 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. The kept cards, a card hand-off's own card, and cards an interrupted + * earlier run already kept go to the head of the queue in the order they were accepted, placed + * once for the whole batch. Throws once every row was tried when one of them could not be written: + * it stays queued for the next open or delivery step, and must not be handed over. + */ +export async function holdJournalLeftoverSends( + journal: AgentSessionJournal, + fence: number +): Promise { + if (journal.isReadOnly) { + return + } + const leftovers = journal + .submissions() + .filter( + (entry) => + isQueuedAgentJournalSubmission(entry) && journal.wroteBeforeOpen(entry.acceptedSequence) + ) + .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) + if (leftovers.length === 0) { + return + } + const { epoch } = journal.cursor() + const kept = leftovers.map((submission) => ({ + submission, + body: leftoverSendHeldAsCard( + submission, + journal.itemBody(agentJournalSubmissionKey(submission.clientMessageId)) + ) + })) + const positions = headOfQueuePositions(journal, kept) + const rejection = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + const failures: unknown[] = [] + for (const [index, { submission, body }] of kept.entries()) { + const { clientMessageId } = submission + const moves = [ + // Cards an interrupted earlier run 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 hold: JournalRowTransactionHook | undefined = + body || moves.length > 0 + ? (db) => { + journal.queuedMessages.holdInTransaction(db, { + card: + body && position !== undefined + ? { + messageId: clientMessageId, + body, + fingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: journal.queuedMessages.sessionId, + fields: { body } + }), + hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, + position + } + : null, + positions: moves + }) + } + : undefined + const reject = { + clientMessageId, + state: 'rejected' as const, + ...rejection, + fence, + recovered: true as const + } + try { + await journal.resolveDispatch(reject, hold) + } catch (error) { + if (!hold) { + failures.push(error) + continue + } + // Keeping it failed: rejected as before, which loses nothing an older build kept. + console.warn('[journal-open] keeping a send a restart interrupted failed:', { + sessionId: journal.queuedMessages.sessionId, + clientMessageId, + 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 sends an earlier host process left queued failed') + } +} + +/** Where each card of the batch goes: the cards it keeps, the hand-offs' own cards, and cards an + * interrupted earlier run kept, in acceptance order, right before every other card. */ +function headOfQueuePositions( + journal: AgentSessionJournal, + kept: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] +): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { + const cards = journal.queuedMessages.list() + const head: (Placed & { earlier?: true })[] = [] + for (const card of cards) { + if ( + card.state === 'waiting' && + card.hostInstance === QUEUED_MESSAGE_HELD_ACROSS_RESTART && + card.carriedFrom === null + ) { + head.push({ + sequence: card.queuedAt?.sequence ?? 0, + move: { messageId: card.messageId, position: 0 }, + earlier: true + }) + } + } + for (const { submission, body } of kept) { + const sequence = submission.acceptedSequence ?? 0 + const { clientMessageId } = submission + if (body) { + head.push({ sequence, move: { messageId: clientMessageId, position: 0 } }) + } else if (cards.some((card) => card.consumedAs === clientMessageId)) { + head.push({ sequence, move: { consumedAs: clientMessageId, position: 0 } }) + } + } + const inHead = (card: (typeof cards)[number]): boolean => + head.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 + head.sort((a, b) => a.sequence - b.sequence) + const placed = head.map((entry, index) => ({ + ...entry, + move: { ...entry.move, position: anchor - head.length + index } + })) + return { + placed: placed.filter((entry) => !entry.earlier).map((entry) => entry.move), + earlier: placed.filter((entry) => entry.earlier).map((entry) => entry.move) + } +} 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 b8fcafc0b6c0..069af53354c3 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 @@ -34,6 +34,7 @@ import { type QueuedMessageRow } from './queued-message-table' import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-message-positions' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { queuedMessageSettlementOwed, @@ -70,6 +71,10 @@ export class JournalQueuedMessages { constructor(private readonly deps: JournalQueuedMessagesDeps) {} + get sessionId(): string { + return this.deps.sessionId + } + revision(): number { return this.changeRevision } @@ -174,15 +179,36 @@ export class JournalQueuedMessages { } /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. - * Returns whether anything changed. */ - adopt(hostInstance: string): Promise { + * `keepHeld` leaves the sends a restart kept (`adoptQueuedMessages`). Returns whether anything + * changed. */ + adopt(hostInstance: string, options: { keepHeld?: boolean } = {}): Promise { const { sessionId } = this.deps return this.transact( - (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), + (db) => adoptQueuedMessages(db, { sessionId, hostInstance, ...options }), (changed) => changed > 0 ).then((changed) => changed > 0) } + /** Inside the caller's journal-row transaction (`journal-leftover-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 0865df3a41fa..fb5b37874ccc 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 @@ -4,6 +4,7 @@ import type { AgentJournalMessageItem, AgentJournalProducerLinkage, AgentJournalRowAttribution, + AgentJournalSubmissionSource, AgentJournalTurnScope, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -308,6 +309,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + source?: AgentJournalSubmissionSource }): JournalSubmissionRow { return { kind: 'submission', @@ -318,6 +320,7 @@ 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 } : {}), + ...(input.source !== undefined ? { source: input.source } : {}) } } 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 9276b1a8c05f..085288f1b7dd 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 @@ -20,6 +20,7 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalSubmissionSource, type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' @@ -134,6 +135,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 + /** Which path sent it (`AgentJournalSubmissionSource`). Absent on rows from before it was + * recorded. Older readers keep the key and ignore it. */ + source?: AgentJournalSubmissionSource } export type JournalSubmissionOrigin = 'client' | 'host' 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 85e6b99f9029..a5932676ade4 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 @@ -7,6 +7,7 @@ import type { AgentJournalProducerLinkage, AgentJournalResetReason, AgentJournalRowAttribution, + AgentJournalSubmissionSource, AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -83,6 +84,8 @@ export type JournalSubmissionInput = { queuedMessageId?: string /** Who asked for this turn (`JournalSubmissionRow.origin`). */ origin?: 'client' | 'host' + /** Which path sent it (`JournalSubmissionRow.source`). */ + source?: AgentJournalSubmissionSource } /** 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 3de15cc3df60..3ec65ac4c9ae 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -63,7 +63,7 @@ import { journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' +import type { JournalRowTransactionHook, JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' @@ -351,9 +351,11 @@ export class AgentSessionJournal { * 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): Promise { - return this.rowWriter.append(journalDispatchRowBuilder(() => this.state, input)) + resolveDispatch(input: ResolveDispatchInput, hook?: JournalRowTransactionHook) { + const build = journalDispatchRowBuilder(() => this.state, input) + return this.rowWriter.append(build, 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 2c9c6cfcd040..bfa61ec0a359 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 @@ -2,8 +2,10 @@ // provider item an accepted message adopts. import { + AGENT_JOURNAL_SUBMISSION_SOURCES, AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalSubmission + type AgentJournalSubmission, + type AgentJournalSubmissionSource } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { journalRenderItem } from './journal-render-item' @@ -30,7 +32,9 @@ 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 } : {}), + // An unknown stored source (a newer build's) is dropped, never the row. + ...(isAgentJournalSubmissionSource(row.source) ? { source: row.source } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. @@ -111,3 +115,7 @@ export function notePersonTurnAccepted( ) } } + +function isAgentJournalSubmissionSource(value: unknown): value is AgentJournalSubmissionSource { + return AGENT_JOURNAL_SUBMISSION_SOURCES.some((source) => source === value) +} 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 00b90a832c1d..9599dc3240fe 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 @@ -4,6 +4,7 @@ import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' +import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from './queued-message-pause' /** Hold waiting drafts from auto-sending. The hold retires with the row: consume * and withdraw clear it in their own UPDATE. Returns how many rows it newly reached. */ @@ -30,19 +31,22 @@ export function holdQueuedMessages( /** Ends a restart's pause: waiting rows another host instance wrote are adopted * into this one, the same fact the pause is derived from, so no second copy * exists. Also clears a per-row 'stopped' hold an earlier build of the queue - * wrote, which this build only ever lifts. Returns how many rows it changed. */ + * wrote, which this build only ever lifts. `keepHeld` leaves the sends a restart + * kept, which only an action on the queue releases. Returns how many rows it changed. */ export function adoptQueuedMessages( db: Database.Database, - input: { sessionId: string; hostInstance: string } + input: { sessionId: string; hostInstance: string; keepHeld?: boolean } ): number { + const kept = input.keepHeld ? QUEUED_MESSAGE_HELD_ACROSS_RESTART : null return Number( db .prepare( `UPDATE queued_messages SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END WHERE session_id = ? AND state = 'waiting' - AND (host_instance <> ? OR hold_reason = 'stopped')` + AND (host_instance <> ? OR hold_reason = 'stopped') + AND (? IS NULL OR host_instance <> ?)` ) - .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 + .run(input.hostInstance, input.sessionId, input.hostInstance, kept, kept).changes ?? 0 ) } 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 b8e45e0ef590..7d9058d6b0fa 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 @@ -24,6 +24,7 @@ import type { AgentSessionJournal } from './journal-store' import { deriveQueuePauses, nextSendableQueuedCard, + QUEUED_MESSAGE_HELD_ACROSS_RESTART, queuePauseHolding, resumableQueuePause } from './queued-message-pause' @@ -517,6 +518,46 @@ describe("a restart's pause", () => { ]) }) + it('a send a restart kept stays held after a person’s turn; adoption leaves it, Resume’s takes it', async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'kept', + body: message('kept across a restart'), + fingerprint: 'fp-kept', + hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART + }) + await journal.queuedMessages.insert({ + messageId: 'foreign', + body: message('another process wrote this'), + fingerprint: 'fp-foreign', + hostInstance: 'proc-0' + }) + const pausesAfterTurn = () => + deriveQueuePauses({ + epoch: journal.epoch, + marks: { latestStop: null, resumedSequence: 0 }, + latestPersonTurnSequence: 9, + cards: journal.queuedMessages.list(), + hostInstance: HOST, + restartEnded: true + }).map((pause) => pause.reason) + expect(pausesAfterTurn()).toEqual(['restarted']) + + expect(await journal.queuedMessages.adopt(HOST, { keepHeld: true })).toBe(true) + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance])).toEqual([ + ['kept', QUEUED_MESSAGE_HELD_ACROSS_RESTART], + ['foreign', HOST] + ]) + expect(pausesAfterTurn()).toEqual(['restarted']) + // Only kept rows left: nothing to adopt, and no write. + const revision = journal.queuedMessages.revision() + expect(await journal.queuedMessages.adopt(HOST, { keepHeld: true })).toBe(false) + expect(journal.queuedMessages.revision()).toBe(revision) + + expect(await journal.queuedMessages.adopt(HOST)).toBe(true) + expect(pausesAfterTurn()).toEqual([]) + }) + it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { const journal = await open() await queueDraft(journal, 'draft-1') 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 9039760ba43c..6223ed1e9cf3 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 @@ -6,7 +6,8 @@ // - '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. +// started since this conversation opened; or a waiting card is a send a restart kept +// (`QUEUED_MESSAGE_HELD_ACROSS_RESTART`), which only Resume, Send now, Edit or Delete releases. // 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. @@ -15,6 +16,11 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' +/** The host instance of a card made from a send accepted before a quit or crash and never handed + * over. Never a live process's id (a UUID), so it reads as another process's card everywhere; no + * later message adopts it, since a surface that cannot show cards would otherwise send it twice. */ +export const QUEUED_MESSAGE_HELD_ACROSS_RESTART = 'held-across-restart' + /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { latestStop: { sequence: number; event: JournalStopEvent } | null @@ -124,7 +130,13 @@ 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)) { + const keptAcrossRestart = waiting.some( + (card) => card.hostInstance === QUEUED_MESSAGE_HELD_ACROSS_RESTART + ) + if ( + keptAcrossRestart || + (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) + ) { // The process that wrote a card is gone: every card waits, whenever it was written. pauses.push({ reason: 'restarted', since: null }) } 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 000000000000..fc940c69dee4 --- /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-leftover-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 cf18a1e5006c..46717268ad10 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-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index a2bd9c3e05ab..225100d02deb 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 @@ -75,13 +75,15 @@ export function insertQueuedMessage( carriedFrom?: string queuedAt: AgentJournalCursor now: number + /** Absent: after every other card. */ + position?: number } ): 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, ?, ?, ?)` 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 b3f91aea7002..088b39d9222b 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 @@ -645,18 +645,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: 'person', + 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-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 73285ee1de4a..2d9c1bdc443e 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 @@ // an earlier host 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-leftover-send-hold.ts`). Nothing here starts a provider child. import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { holdJournalLeftoverSends } from '../agent-session-journal/journal-leftover-send-hold' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -100,8 +102,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 opened.journal.markPendingSubmissionsUnknown(fence) } catch (error) { deps.logger.warn('marking pending sends unknown on open failed', { @@ -110,6 +111,17 @@ export async function openStructuredAgentSessionConversationJournal( error }) } + try { + // Before a Stop this open serves can withdraw one: a Stop never withdraws a card. + await holdJournalLeftoverSends(opened.journal, fence) + } catch (error) { + // The delivery loop's first step retries it, and hands nothing over until it lands. + 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 0414836f4a5e..c47668b31c6d 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,8 @@ // 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: its next open keeps it as a held card. import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -17,10 +18,8 @@ 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 { holdJournalLeftoverSends } from '../agent-session-journal/journal-leftover-send-hold' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { structuredAgentSessionStartFailure, @@ -156,7 +155,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 are settled by the next open. this.running.delete(sessionId) this.deps.logger.warn('recording a failed delivery failed', { scope: 'delivery-loop-fail', @@ -173,12 +172,9 @@ 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. + await holdJournalLeftoverSends(session.journal, this.deps.conversationFence(sessionId)) 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) 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 60c20cb768d9..4dfe37aa154d 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 @@ -61,10 +61,10 @@ 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. */ +/** What is still queued when the chat closes will not be handed over. A quit is not a close: its + * leftovers are kept by the next open (`journal-leftover-send-hold.ts`). Best effort: the next open + * settles 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( deps: ConversationCloseDeps, sessionId: string, 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 3fbb96ddaf87..90e6db2f0208 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 @@ -60,6 +60,8 @@ export function sendStructuredAgentSessionTurn( * Orchestration mail, a restart continuation and `agent.launch`'s host-sent * prompt never set it. */ userSend?: true + /** Which host-internal path sent it, when not a person (`AgentJournalSubmissionSource`). */ + source?: 'launch' | 'mail' | 'continuation' beforeRun?: () => void } ): Promise> { 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 65bf219cf79d..3c0258e040d5 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 '../../codex/codex-app-server-po 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 is already disposed, so nothing hands it over now, + // and the next open keeps a person's message as a held card. `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-leftover-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-send-hold.test.ts new file mode 100644 index 000000000000..366b6c23977c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-send-hold.test.ts @@ -0,0 +1,377 @@ +// A message the host accepted while the agent was starting, then Orca quit or crashed before handing +// it over: the next open keeps it as a held card at the head of the queue, never sends it on its +// own, and never lets a later message release 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 { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from '../agent-session-journal/queued-message-pause' +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 { + 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' + +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) + +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, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + origin: 'client', + source: 'person' + }) + const [card] = journal().queuedMessages.list() + expect(card).toMatchObject({ + messageId: id, + hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + 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, state: 'waiting' }]) + 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, state: 'waiting' }, + { messageId: second.envelope.clientOperationId, state: 'waiting' } + ]) + }) +}) + +describe('only an action on the queue releases a kept card', () => { + it('a later message is delivered alone; Resume then sends the kept one 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, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(id)).toBeDefined()) + const handoff = await rig.handoff(id) + expect(handoff?.clientMessageId).not.toBe(id) + expect(handoff).toMatchObject({ origin: 'host', source: 'queue' }) + await eventually(() => expect(dispatchedTexts()).toEqual(['the kept words', 'the kept words'])) + }) + + 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', source: 'queue' } } + }) + await eventually(() => expect(dispatchedTexts()).toEqual(['send me now'])) + 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, state: 'waiting' }]) + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + }) +}) + +// 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, state: 'waiting' }]) + 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, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + 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, state: 'waiting' }]) + }) + + 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, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(rig.dispatch).not.toHaveBeenCalled() + }) +}) + +describe('what is not kept', () => { + it('orchestration mail and a send from no person are rejected, as before', async () => { + const { userSend: _person, ...mail } = sendRequest('mail') + await acceptWhileStarting({ ...mail, source: 'mail' }) + await rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([]) + expect(await rig.submission(mail.envelope.clientOperationId)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED + }) + }) + + 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([]) + expect(warned).toHaveBeenCalledWith( + '[journal-open] keeping a send a restart interrupted failed:', + expect.objectContaining({ clientMessageId: id }) + ) + }) + + 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, state: 'waiting' }]) + ) + 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 lifts the clear, never the restart', 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, state: 'waiting' }]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal + expect(carried?.queuedMessages.list()[0]?.hostInstance).toBe(QUEUED_MESSAGE_HELD_ACROSS_RESTART) + + 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 eventually(async () => + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'restarted' }) + ) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, state: 'waiting' }]) + 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-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c88..10fbac72876d 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 @@ -5,7 +5,10 @@ // operation happened, so the durable answer usually comes back out of the // journal. Send is fail-closed: admission alone cannot prove non-delivery. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalSubmissionSource +} from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { @@ -65,6 +68,8 @@ export function sendPlan(params: { retryUnknown?: true delivery?: 'queue-if-active' userSend?: true + /** Which path sent it; a person's send (`userSend`) is always `person`. */ + source?: Extract beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -84,8 +89,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 ? 'person' : params.source return performSend(ctx, { origin: params.userSend ? 'client' : 'host', + ...(source ? { source } : {}), clientMessageId, payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body @@ -93,8 +100,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 a restart 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 } @@ -149,6 +161,7 @@ export function conversationCommandPlan(params: { clientMessageId, // Only a client asks through the command RPC: the person's own turn. origin: 'client', + source: 'person', 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 6d42155fa386..b392ceca7619 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 @@ -150,9 +150,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 } @@ -608,7 +608,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() @@ -616,17 +622,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 () => { @@ -641,7 +660,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() @@ -651,10 +670,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]) }) }) 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 54cfbb470128..0381de176355 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 @@ -284,6 +284,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' }) @@ -330,6 +336,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 7d0eb8581e66..55e2a26ed1a5 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 @@ -350,6 +350,7 @@ export class StructuredAgentSessionQueuedMessageDrain { clientMessageId: submissionId, // The queue's own automatic send: it never ends a pause. origin: 'host', + source: 'queue', payloadFingerprint: next.fingerprint, body: next.body, fence, 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 ee32b3648a90..460348c8b741 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 @@ -21,6 +21,7 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' +import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from '../agent-session-journal/queued-message-pause' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { queuedMessageFingerprint, @@ -116,7 +117,11 @@ export async function carryQueuedMessagesToClearReplacement( messageId: row.messageId, body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), - hostInstance: structuredAgentSessionHostInstance(), + // A send a restart kept stays held there too: no later message may release it. + hostInstance: + row.hostInstance === QUEUED_MESSAGE_HELD_ACROSS_RESTART + ? QUEUED_MESSAGE_HELD_ACROSS_RESTART + : structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId }) } @@ -223,6 +228,7 @@ export function sendQueuedStructuredAgentMessage( clientMessageId: submissionId, // The person asked for this turn, so it ends a Stop's pause once it starts. origin: 'client', + source: 'queue', payloadFingerprint: row.fingerprint, body: row.body, fence: ctx.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 7b85ede6fe92..c24bf0416461 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -4,7 +4,10 @@ import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { DerivedQueuePause } from '../agent-session-journal/queued-message-pause' +import { + QUEUED_MESSAGE_HELD_ACROSS_RESTART, + type DerivedQueuePause +} from '../agent-session-journal/queued-message-pause' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own @@ -32,7 +35,9 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ /** * Every journal publish: a restart's rows are adopted into this instance once a person's turn * started. The derivation already reads them as lifted; the write keeps that answer when the - * handle reopens (its "since this conversation opened" moves). Bookkeeping: a failure is reported. + * handle reopens (its "since this conversation opened" moves). A send a restart kept is not: a + * person's later message must never release it, since one who cannot see cards may have typed the + * same words again. Bookkeeping: a failure is reported. */ export async function adoptEndedRestartPause( sessionId: string, @@ -43,9 +48,14 @@ export async function adoptEndedRestartPause( const { queuedMessages } = journal const restarted = queuedMessages .list() - .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) + .some( + (row) => + row.state === 'waiting' && + row.hostInstance !== hostInstance && + row.hostInstance !== QUEUED_MESSAGE_HELD_ACROSS_RESTART + ) if (restarted && queuedMessages.restartEnded()) { - await queuedMessages.adopt(hostInstance) + await queuedMessages.adopt(hostInstance, { keepHeld: true }) } } catch (error) { logger.warn("adopting a restart's queued cards after a started turn failed", { @@ -57,7 +67,7 @@ export async function adoptEndedRestartPause( } /** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's - * rows. Returns whether the queue was paused. */ + * rows, the sends it kept included. Returns whether the queue was paused. */ export async function resumeStructuredQueue( journal: Pick, fence: number 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 a46551b4500e..a9baaa932b29 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 @@ -131,6 +131,9 @@ it('replays the same logical continuation through the durable send ledger', asyn if (!sent) { throw new Error('the continuation was not sent') } + // Re-derived by its own records after another restart, so never kept as a card. + expect(sent.source).toBe('continuation') + expect((await host.journalSnapshot(SESSION)).submissions[0]?.source).toBe('continuation') 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-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 2284367c14b8..58ef1536b9c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -48,6 +48,7 @@ type RestartResumeHostBindings = { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem beforeRun?: () => void + source: 'continuation' } ) => Promise> /** The host's existing settlement waiter; a send returns while its dispatch is still pending. */ @@ -65,7 +66,10 @@ export function structuredAgentSessionRestartResumeSurfaces( return { revealSession: host.revealSession, send: (params) => - host.send({ callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, params), + host.send( + { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, + { ...params, source: 'continuation' } + ), // Accepted like any send, so its verdict is its delivery, however long the start takes; the // wait ends when the submission settles or the session closes. awaitSendSettlement: (sessionId, clientMessageId) => 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 4fa2811472a4..b4253800c84b 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 @@ -113,7 +113,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( @@ -133,7 +133,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: 'person' as const } + : {}) }) if (midWork === 'running tool call') { await journal.appendItem( @@ -142,6 +146,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. @@ -171,11 +179,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) @@ -328,7 +341,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) @@ -349,6 +362,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, host_instance, state FROM queued_messages WHERE session_id = ?') + .all('chat-kept') + expect(cards).toEqual([ + { message_id: 'client-chat-kept', host_instance: 'held-across-restart', 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 6ee38fa1d62a..71832ce6210c 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 @@ -13,7 +13,8 @@ import { } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, - AgentJournalSubmission + AgentJournalSubmission, + AgentJournalSubmissionSource } from '../../../shared/agent-session-journal-types' import { refuse, @@ -126,6 +127,7 @@ export async function performSend( body: AgentJournalMessageItem /** Who asked for the turn; absent on callers that predate it. */ origin?: 'client' | 'host' + source?: AgentJournalSubmissionSource } ): Promise> { const existing = ctx.journal 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 11647f2eefde..56be3f00d598 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 @@ -183,7 +183,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 keeps it as a card. const handedOver = snapshot.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) ) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index fee8909a4458..0022ad3a482e 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -108,6 +108,8 @@ describe('structured mailbox pointer host', () => { // Per-dispatch, so one worker's nudges cannot exhaust the shared operation-ledger budget. expect(send.mock.calls[0]![0]).toEqual({ callerKey: structuredPointerCallerKey('d1') }) expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined() + // The mailbox sends mail again after a restart, so a restart never keeps it as a card. + expect(send.mock.calls[0]![1]).toMatchObject({ source: 'mail' }) }) it('consumes mail once an accepted nudge is delivered while the worker starts (W10)', async () => { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index ecfdff0dff5f..148014c06ee5 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -106,7 +106,8 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo expectedRuntimeFence: input.expectedRuntimeFence, payloadFingerprint: input.payloadFingerprint }, - body: input.body + body: input.body, + source: 'mail' } ) if (!result.ok) { 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 1b5910519361..6a979bb454a1 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).toBe('launch') // 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 00c779fffe0b..3b7a1ad4bb95 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -54,10 +54,10 @@ 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), + source: 'launch' + }) 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/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 29432e643304..a7e1391a02c1 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -117,7 +117,8 @@ export function NativeChatQueuedMessageCard({ onSteer: () => 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 +202,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 41fd4852a202..916cefba3dea 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, @@ -470,4 +472,32 @@ describe('NativeChatQueuedMessageList', () => { fireEvent.click(await screen.findByRole('menuitem', { name: 'Turn off queueing' })) expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) + + // A message a restart kept 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 steer chord only when the host queues sends ($queueCapable)', + async ({ queueCapable, offered }) => { + const owner = controller( + [card({ messageId: 'kept', hold: 'queue-paused' })], + { reason: 'restarted' }, + queueCapable + ) + renderList(owner) + fireEvent.focus(screen.getByRole('button', { name: 'Steer' })) + const hint = await screen.findAllByText('Submit without interrupting the model') + 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) + // Resume, 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: 'Resume' })) + expect(owner.resume).toHaveBeenCalled() + } + ) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 17223e0194cb..5a107e308b87 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 restart's 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/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 26e0fd71112b..cfe2ded5a75d 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 @@ -177,6 +177,67 @@ 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({ + 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) + } + ) + 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-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 4596fb67180f..588c1817a3e5 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 @@ -25,6 +25,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 a restart kept — 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 @@ -178,5 +181,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/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0f..7349b705e6eb 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -427,8 +427,22 @@ export type AgentJournalSubmission = { /** Host-only: who asked for this turn — a person over the client send RPC, or Orca itself. * A person's turn is what ends a Stop's queue pause. */ origin?: 'client' | 'host' + /** Host-only: which path sent it, so a restart knows which leftovers to keep as cards. */ + source?: AgentJournalSubmissionSource } +/** Which path sent a submission: a person's send (typed, or `/compact`), `agent.launch`'s first + * prompt, orchestration mail, a restart continuation, or the queue handing off a card. */ +export type AgentJournalSubmissionSource = 'person' | 'launch' | 'mail' | 'continuation' | 'queue' + +export const AGENT_JOURNAL_SUBMISSION_SOURCES: readonly AgentJournalSubmissionSource[] = [ + 'person', + 'launch', + 'mail', + 'continuation', + 'queue' +] + /** Durable answer to "did my send land?", keyed by client message id. Only an * `accepted` dispatch mints one, and it outlives the journal tail. */ export type AgentJournalAcceptanceReceipt = { diff --git a/tests/e2e/cross-version-wire/leftover-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/leftover-card-downgrade.unit.test.ts new file mode 100644 index 000000000000..1f1d6bffc949 --- /dev/null +++ b/tests/e2e/cross-version-wire/leftover-card-downgrade.unit.test.ts @@ -0,0 +1,170 @@ +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 +} from '../../../src/shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { holdJournalLeftoverSends } from '../../../src/main/native-chat/agent-session-journal/journal-leftover-send-hold' +import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from '../../../src/main/native-chat/agent-session-journal/queued-message-pause' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A send a restart kept is an ordinary waiting card: a value in the host-instance column and a +// place ahead of the queue, no new state or column. A build from before it must still list it, +// first, under its restart pause, and must still settle a leftover this build's quit left queued. +// The main commit this change branched from; move it to the first release that holds this change. +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: { 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 } + +type OlderJournal = { + queuedMessages: { + list: () => readonly OlderCard[] + pauses: (hostInstance: string) => { reason: string }[] + } + 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: AgentSessionJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise +} + +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: 'person' + }) +} + +test('an older build lists a kept card first, held by its restart pause', 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' + }) + await acceptPersonSend(earlier, 'kept') + await journals.closeAll() + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await holdJournalLeftoverSends(reopened, 0) + expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ + 'kept', + 'queued-card' + ]) + await journals.closeAll() + + const older = await olderOpener() + try { + const downgraded = await older.open({ identity: IDENTITY, stateDirectory: directory }) + expect( + downgraded.queuedMessages.list().map(({ messageId, state, hostInstance }) => ({ + messageId, + state, + hostInstance + })) + ).toEqual([ + { messageId: 'kept', state: 'waiting', hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART }, + { messageId: 'queued-card', state: 'waiting', hostInstance: 'host-a' } + ]) + expect(downgraded.queuedMessages.list()[0]!.position).toBeLessThan(1) + expect(downgraded.queuedMessages.pauses('host-b').map((pause) => pause.reason)).toEqual([ + 'restarted' + ]) + expect(downgraded.submission('kept')).toMatchObject({ dispatchState: 'rejected' }) + } 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: 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) From 2880804e489985fc24738f55d99d666d4cad6557 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:28:34 -0700 Subject: [PATCH 02/22] fix(native-chat): keep an unsent message as a held card at every close, not only a restart The host now keeps a person's message it accepted and never handed over with one rule wherever it can no longer hand it over: a quit or crash (settled at the next open) and a close of the chat (tab close, worktree teardown, orchestration stop). - The hold is card state: a new per-card hold_reason 'kept', published as the existing pausedReason, instead of a fake host_instance value. host_instance means the owner again, and the pause clause, adoption filter and /clear carry special cases are gone. A kept card holds the cards behind it until the person sends, edits or deletes it; a send_failed card still does not. - A card hand-off rejected by a restart or a close returns as a kept card (rejectedDraftSettlement), so a person's next message can never release it. - One hold function, parameterized by cause (hostRestarted / chatClosed), replaces the close path's plain rejection. - The phone shows published cards and per-card holds whatever the queue capability says; only queueing a new send stays gated. - source gains 'dispatch' for the orchestration preamble (still rejected); an unknown source is kept as written and never takes the legacy rule. - Kept cards from earlier settlements stay ahead of a batch's new ones. --- .../MobileNativeChatQueuedMessages.test.tsx | 1 + ...le-structured-queued-message-cards.test.ts | 31 +++- .../mobile-structured-queued-message-cards.ts | 17 +- ...se-mobile-native-chat-queued-slot.test.tsx | 1 + ...e-structured-agent-session-queued.test.tsx | 27 ++- .../use-mobile-structured-agent-session.ts | 3 +- ...bile-structured-queued-message-controls.ts | 28 ++- .../journal-dispatch-settlement.test.ts | 16 +- .../journal-dispatch-settlement.ts | 21 +-- .../journal-queued-messages.ts | 13 +- .../journal-submission-fold.ts | 12 +- ...st.ts => journal-unsent-send-hold.test.ts} | 151 +++++++++++++--- ...nd-hold.ts => journal-unsent-send-hold.ts} | 136 +++++++------- .../queued-message-holds.ts | 15 +- .../queued-message-pause.test.ts | 63 ++++--- .../queued-message-pause.ts | 44 ++--- .../queued-message-positions.ts | 2 +- .../queued-message-store.test.ts | 7 +- .../queued-message-table.ts | 35 ++-- ...-session-claude-unproven-stop-send.test.ts | 2 +- ...red-agent-session-conversation-lifetime.ts | 8 +- ...uctured-agent-session-conversation-open.ts | 14 +- .../structured-agent-session-delivery-loop.ts | 23 ++- .../structured-agent-session-host-delivery.ts | 6 +- .../structured-agent-session-host-lifetime.ts | 47 +++-- ...structured-agent-session-host-mutations.ts | 7 +- ...structured-agent-session-mutation-plans.ts | 8 +- ...gent-session-provider-child-record.test.ts | 32 +++- ...ructured-agent-session-queued-mutations.ts | 28 +-- .../structured-agent-session-queued-pause.ts | 20 +-- ...ctured-agent-session-queued-publication.ts | 15 +- ...ent-session-restore-without-import.test.ts | 5 +- ...ed-agent-session-unsent-send-hold.test.ts} | 169 +++++++++++++----- ...tured-agent-session-working-at-teardown.ts | 2 +- ...orchestration-structured-worker-session.ts | 3 +- .../NativeChatQueuedMessageCard.tsx | 11 +- .../NativeChatQueuedMessageList.test.tsx | 37 ++-- .../NativeChatQueuedMessageList.tsx | 2 +- ...uctured-agent-session-queued-cards.test.ts | 15 ++ .../structured-agent-session-queued-cards.ts | 11 +- ...tructured-agent-session-queued-messages.ts | 4 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/agent-session-journal-types.ts | 31 ++-- .../agent-session-queued-message-wire.ts | 14 +- ...st.ts => kept-card-downgrade.unit.test.ts} | 78 ++++++-- 50 files changed, 804 insertions(+), 417 deletions(-) rename src/main/native-chat/agent-session-journal/{journal-leftover-send-hold.test.ts => journal-unsent-send-hold.test.ts} (63%) rename src/main/native-chat/agent-session-journal/{journal-leftover-send-hold.ts => journal-unsent-send-hold.ts} (50%) rename src/main/native-chat/agent-session-wire/{structured-agent-session-leftover-send-hold.test.ts => structured-agent-session-unsent-send-hold.test.ts} (69%) rename tests/e2e/cross-version-wire/{leftover-card-downgrade.unit.test.ts => kept-card-downgrade.unit.test.ts} (65%) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a57..d27eed1bafa5 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -34,6 +34,7 @@ function card( text: `text of ${overrides.messageId}`, state: 'waiting', paused: false, + kept: false, needsAttention: false, caption: null, ...overrides 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 44d1e0b9729f..396f0a92ab86 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, @@ -44,6 +47,7 @@ describe('mobileQueuedMessageCards', () => { text: 'body of a', state: 'waiting', paused: false, + kept: false, needsAttention: false, caption: null }) @@ -99,6 +103,31 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) + // A kept card blocks what is behind it, as a returned one does. + const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }) + expect(resumable([kept, waiting])).toBe(false) + }) + + // The host kept it unsent across a restart or a close; nothing behind it sends until it does. + it('captions a kept card as not sent yet, and the cards behind it as waiting on it', () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }), + draft({ messageId: 'b', position: 2 }) + ], + [], + { pendingPrompt: false } + ) + expect( + cards.map(({ caption, kept, needsAttention }) => ({ caption, kept, needsAttention })) + ).toEqual([ + { caption: 'Not sent yet — tap Send to send it', kept: true, needsAttention: false }, + { + caption: 'Waiting — a message ahead needs attention', + kept: false, + 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 f3edb13e53c8..e6b90a2bb3fa 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 @@ -19,6 +20,8 @@ export type MobileQueuedMessageCard = { text: string state: 'waiting' | 'returned' paused: boolean + /** Held because the host kept it unsent; the cards behind it wait for it. */ + kept: boolean /** Returned, or its own send failed: the row leads with an alert. */ needsAttention: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ @@ -48,11 +51,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' } @@ -64,10 +71,10 @@ const QUEUE_PAUSE_LABELS: Readonly> = { } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any - * returned card. The drain stops at a returned card, so cards behind one never go. */ + * returned or kept card. The drain stops at either, so cards behind one never go. */ export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { for (const card of cards) { - if (card.state === 'returned') { + if (card.state === 'returned' || card.kept) { return false } if (!card.paused) { @@ -123,17 +130,19 @@ export function mobileQueuedMessageCards( : facts.pendingPrompt ? 'Waiting for your answer' : null + const kept = paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT cards.push({ messageId: draft.messageId, text: queuedMessageBodyText(draft.body), state: draft.state, paused, + kept, needsAttention: draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), caption }) - if (draft.state === 'returned') { + if (draft.state === 'returned' || kept) { behindReturned = true } } diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx index 002a567548f6..aa3c2be26618 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -32,6 +32,7 @@ const CARD = { text: 'next', state: 'waiting' as const, paused: false, + kept: false, needsAttention: false, caption: null } 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 277beb17d75a..b3c1cdbb3fc6 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 @@ -532,6 +532,7 @@ describe('mobile structured queued messages', () => { text: 'text of draft-1', state: 'waiting', paused: false, + kept: false, needsAttention: false, caption: null } @@ -568,12 +569,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: 'Waiting — a message ahead needs attention' } + ]) + // Resume would send nothing past the kept card, so it is not offered. + expect(hook!.queued.pause).toBeNull() }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 653d7fbaaa1a..372dd9fab5e6 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -94,7 +94,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 @@ -177,7 +177,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 4e033c6dd972..f65ecf2d46e3 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-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts index 7f9711151fc2..052ee2e6fd08 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 @@ -18,18 +18,22 @@ function settle(kind: SubmissionRejectionKind) { 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' }) + // The host keeps what it accepted and never sent: the card waits for the person's own Send. + it('a restart or close before hand-over sends it back to waiting, kept', () => { + for (const kind of ['hostRestarted', 'chatClosed'] as const) { + expect(settle(kind)).toEqual({ state: 'waiting', kept: true }) } expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ - state: 'waiting' + state: 'waiting', + kept: true }) }) 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 7e1d6949bf35..a41dbc0e1b87 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,23 @@ 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. A restart or a + * close that interrupted it before hand-over keeps it held (`kept`) until the person sends it, as + * the host keeps every message it accepted and never sent; a Stop's withdrawal leaves it under the + * Stop's own pause. 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 } ): RejectedDraftSettlement { - return classifyDispatchRejection(rejection).verdict === null - ? { state: 'waiting' } - : { state: 'returned' } + const { verdict, kind } = classifyDispatchRejection(rejection) + if (verdict !== null) { + return { state: 'returned' } + } + return { state: 'waiting', kept: kind === 'hostRestarted' || kind === 'chatClosed' } } /** 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 069af53354c3..2cfdac4b5aa2 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 @@ -105,13 +105,15 @@ 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. */ + /** `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. */ insert(input: { messageId: string body: AgentJournalMessageItem fingerprint: string hostInstance: string carriedFrom?: string + holdReason?: QueuedMessageHoldReason }): Promise { const { sessionId } = this.deps let inserted = false @@ -179,17 +181,16 @@ export class JournalQueuedMessages { } /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. - * `keepHeld` leaves the sends a restart kept (`adoptQueuedMessages`). Returns whether anything - * changed. */ - adopt(hostInstance: string, options: { keepHeld?: boolean } = {}): Promise { + * Returns whether anything changed. */ + adopt(hostInstance: string): Promise { const { sessionId } = this.deps return this.transact( - (db) => adoptQueuedMessages(db, { sessionId, hostInstance, ...options }), + (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), (changed) => changed > 0 ).then((changed) => changed > 0) } - /** Inside the caller's journal-row transaction (`journal-leftover-send-hold.ts`): one kept send + /** 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( 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 bfa61ec0a359..271a245f5be1 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 @@ -2,10 +2,8 @@ // provider item an accepted message adopts. import { - AGENT_JOURNAL_SUBMISSION_SOURCES, AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalSubmission, - type AgentJournalSubmissionSource + type AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { journalRenderItem } from './journal-render-item' @@ -33,8 +31,8 @@ export function applyJournalSubmission( ? { queuedMessageId: row.queuedMessageId } : {}), ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), - // An unknown stored source (a newer build's) is dropped, never the row. - ...(isAgentJournalSubmissionSource(row.source) ? { source: row.source } : {}) + // Kept as written, a newer build's too: an unknown source must never read as a row without one. + ...(typeof row.source === 'string' ? { source: row.source } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. @@ -115,7 +113,3 @@ export function notePersonTurnAccepted( ) } } - -function isAgentJournalSubmissionSource(value: unknown): value is AgentJournalSubmissionSource { - return AGENT_JOURNAL_SUBMISSION_SOURCES.some((source) => source === value) -} diff --git a/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts similarity index 63% rename from src/main/native-chat/agent-session-journal/journal-leftover-send-hold.test.ts rename to src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index 23417262fd36..e5c739abfeb4 100644 --- a/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -1,5 +1,5 @@ -// Which sends an earlier host process left queued become held cards, where they go in the queue, -// and that a send's source is recorded and folded. +// 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' @@ -13,11 +13,15 @@ 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 { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' -import { holdJournalLeftoverSends, leftoverSendHeldAsCard } from './journal-leftover-send-hold' +import { + holdUnsentSends, + unsentSendKeptAsCard, + type UnsentSendHold +} from './journal-unsent-send-hold' import type { AgentSessionJournal } from './journal-store' -import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from './queued-message-pause' import { closeTestJournalHostDatabases, createTrackedJournalOpener @@ -33,9 +37,15 @@ const IDENTITY: AgentSessionJournalIdentity = { 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 { @@ -55,10 +65,14 @@ async function open(): Promise { identity: IDENTITY, stateDirectory: root, now: () => (clock += 1), - mintEpoch: () => 'epoch-1' + mintEpoch: () => `epoch-${(epochs += 1)}` }) } +function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'hostRestarted' }) { + return holdUnsentSends(journal, { fence: 0, hostInstance: HOST, hold: settle }) +} + async function accept( journal: AgentSessionJournal, id: string, @@ -98,7 +112,8 @@ function cardOrder(journal: AgentSessionJournal): string[] { } beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-leftover-hold-')) + epochs = 0 + root = await mkdtemp(join(tmpdir(), 'orca-unsent-hold-')) }) afterEach(async () => { @@ -107,21 +122,22 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('which leftover sends are kept', () => { +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: 'person' }) await accept(earlier, 'launch', { origin: 'host', source: 'launch' }) await accept(earlier, 'legacy-client', { origin: 'client' }) }) - await holdJournalLeftoverSends(journal, 0) + await hold(journal) expect(cardOrder(journal)).toEqual(['person', 'launch', 'legacy-client']) for (const id of ['person', 'launch', 'legacy-client']) { expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) expect(journal.queuedMessages.get(id)).toMatchObject({ state: 'waiting', - hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + hostInstance: HOST, body: message(`text of ${id}`), fingerprint: fingerprint(message(`text of ${id}`)), carriedFrom: null, @@ -130,7 +146,7 @@ describe('which leftover sends are kept', () => { } }) - it('rejects mail, a continuation, /compact, an image, and a send no one can attribute', async () => { + 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', @@ -141,6 +157,7 @@ describe('which leftover sends are kept', () => { } const journal = await afterRestart(async (earlier) => { await accept(earlier, 'mail', { origin: 'host', source: 'mail' }) + await accept(earlier, 'dispatch', { origin: 'host', source: 'dispatch' }) await accept(earlier, 'continuation', { origin: 'host', source: 'continuation' }) await accept(earlier, 'compact', { origin: 'client', @@ -151,15 +168,23 @@ describe('which leftover sends are kept', () => { await accept(earlier, 'legacy-host', { origin: 'host' }) await accept(earlier, 'no-origin') }) - await holdJournalLeftoverSends(journal, 0) + await hold(journal) expect(journal.queuedMessages.list()).toEqual([]) - for (const id of ['mail', 'continuation', 'compact', 'image', 'legacy-host', 'no-origin']) { + for (const id of [ + 'mail', + 'dispatch', + 'continuation', + 'compact', + 'image', + 'legacy-host', + 'no-origin' + ]) { expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) } }) - it('a card’s own hand-off returns its card, and makes no second one', async () => { + it('a card’s own hand-off returns its card kept, and makes no second one', async () => { const journal = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'card', @@ -180,27 +205,52 @@ describe('which leftover sends are kept', () => { { messageId: 'card', expect: 'waiting', settledByOp: null } ) }) - await holdJournalLeftoverSends(journal, 0) + await hold(journal) expect(journal.queuedMessages.list()).toHaveLength(1) - expect(journal.queuedMessages.get('card')).toMatchObject({ state: 'waiting', consumedAs: null }) + expect(journal.queuedMessages.get('card')).toMatchObject({ + state: 'waiting', + consumedAs: null, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }) 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: 'person' }) - await holdJournalLeftoverSends(journal, 0) + 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(leftoverSendHeldAsCard({ source: 'person' }, body)).toBe(body) - expect(leftoverSendHeldAsCard({ source: 'queue' }, body)).toBeNull() - expect(leftoverSendHeldAsCard({ origin: 'client', queuedMessageId: 'card' }, body)).toBeNull() - expect(leftoverSendHeldAsCard({ origin: 'client' }, null)).toBeNull() + expect(unsentSendKeptAsCard({ source: 'person' }, body)).toBe(body) + expect(unsentSendKeptAsCard({ source: 'queue' }, 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 source may name a path + // that must not become a card. + it('never keeps a source 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', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer build's source, which this build's type cannot name. + source: 'a-newer-path' as AgentJournalSubmissionSource + }) + }) + expect(journal.submission('future')?.source).toBe('a-newer-path') + await hold(journal) + expect(journal.queuedMessages.list()).toEqual([]) + expect(journal.submission('future')).toMatchObject({ dispatchState: 'rejected' }) }) }) @@ -234,7 +284,7 @@ describe('where kept sends go in the queue', () => { ) await accept(earlier, 'B', { origin: 'client', source: 'person' }) }) - await holdJournalLeftoverSends(journal, 0) + await hold(journal) expect(cardOrder(journal)).toEqual(['A', 'H', 'B', 'C']) }) @@ -259,7 +309,8 @@ describe('where kept sends go in the queue', () => { messageId: 'A', body: message('text of A'), fingerprint: fingerprint(message('text of A')), - hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + hostInstance: HOST, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch: 'epoch-1', sequence: interrupted.submission('A')!.acceptedSequence! @@ -272,12 +323,66 @@ describe('where kept sends go in the queue', () => { ) await interrupted.close() const journal = await open() - await holdJournalLeftoverSends(journal, 0) + 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: 'person' }) + await accept(journal, 'mail', { origin: 'host', source: 'mail' }) + 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: 'person' }) + await accept(journal, 'after', { origin: 'client', source: 'person' }) + 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' }) + } + await accept(earlier, 'A', { origin: 'client', source: 'person' }) + }) + await hold(journal) + await journal.rollEpoch('handle_forked', 0) + await accept(journal, 'B', { origin: 'client', source: 'person' }) + 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() diff --git a/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts similarity index 50% rename from src/main/native-chat/agent-session-journal/journal-leftover-send-hold.ts rename to src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 93214791bcf4..e952590f4db1 100644 --- a/src/main/native-chat/agent-session-journal/journal-leftover-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -1,9 +1,9 @@ -// What becomes of a send an earlier host process accepted and never handed over: it was quit or -// crashed first, so the agent provably never got it. A person's message (typed, or a launch's first -// prompt) is kept as a waiting card at the head of the queue, held until someone acts on the queue -// (`QUEUED_MESSAGE_HELD_ACROSS_RESTART`). Everything else is rejected as before, because something -// else re-derives it or the person re-runs it. Either way the submission itself is rejected, so it -// is never handed over twice. +// 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, @@ -12,21 +12,29 @@ import type { } 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 { 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 { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from './queued-message-pause' import type { QueuedMessagePositionMove } from './queued-message-positions' -/** The body a leftover send is kept with, or null when it is rejected instead: - * - a card's own hand-off: its rejection already sends the card back to waiting; +/** 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, kept (`rejectedDraftSettlement`); * - `/compact` and other commands: a command in flight is not resumed, the person re-runs it; * - an image: cards are text-only; - * - orchestration mail (the mailbox re-sends it), a restart continuation (its offer is - * re-derived), and a row with no source that is not a person's (it could be either). */ -export function leftoverSendHeldAsCard( + * - orchestration mail (the mailbox re-sends it), a dispatch preamble or a restart continuation + * (their owners re-derive them), a source 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 { @@ -45,50 +53,51 @@ export function leftoverSendHeldAsCard( return persons ? body : null } -type Placed = { sequence: number; move: QueuedMessagePositionMove } - /** - * Settles every send an earlier host process left queued. Each is rejected as `hostRestarted` 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. The kept cards, a card hand-off's own card, and cards an interrupted - * earlier run already kept go to the head of the queue in the order they were accepted, placed - * once for the whole batch. Throws once every row was tried when one of them could not be written: - * it stays queued for the next open or delivery step, and must not be handed over. + * 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 a card hand-off's own card, go to the head of the queue in the + * order they were accepted, behind the cards an earlier settlement kept. Throws once every row was + * tried when one of them could not be written at all: that row stays queued. */ -export async function holdJournalLeftoverSends( +export async function holdUnsentSends( journal: AgentSessionJournal, - fence: number + input: { fence: number; hostInstance: string; hold: UnsentSendHold } ): Promise { if (journal.isReadOnly) { return } - const leftovers = journal + const { hold } = input + const unsent = journal .submissions() .filter( (entry) => - isQueuedAgentJournalSubmission(entry) && journal.wroteBeforeOpen(entry.acceptedSequence) + isQueuedAgentJournalSubmission(entry) && + (hold.cause === 'hostRestarted' + ? journal.wroteBeforeOpen(entry.acceptedSequence) + : (hold.which?.(entry) ?? true)) ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) - if (leftovers.length === 0) { + if (unsent.length === 0) { return } const { epoch } = journal.cursor() - const kept = leftovers.map((submission) => ({ + const kept = unsent.map((submission) => ({ submission, - body: leftoverSendHeldAsCard( + body: unsentSendKeptAsCard( submission, journal.itemBody(agentJournalSubmissionKey(submission.clientMessageId)) ) })) const positions = headOfQueuePositions(journal, kept) - const rejection = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + const rejection = agentSessionFailureWords(agentSessionFailureFact(hold.cause), { surface: 'rejection' }) const failures: unknown[] = [] for (const [index, { submission, body }] of kept.entries()) { const { clientMessageId } = submission const moves = [ - // Cards an interrupted earlier run kept move with the first row. + // The cards an earlier settlement kept move with the first row. ...(index === 0 ? positions.earlier : []), ...(submission.queuedMessageId !== undefined ? positions.placed.filter( @@ -99,7 +108,7 @@ export async function holdJournalLeftoverSends( const position = positions.placed.find( (entry) => 'messageId' in entry && entry.messageId === clientMessageId )?.position - const hold: JournalRowTransactionHook | undefined = + const keep: JournalRowTransactionHook | undefined = body || moves.length > 0 ? (db) => { journal.queuedMessages.holdInTransaction(db, { @@ -113,7 +122,8 @@ export async function holdJournalLeftoverSends( sessionId: journal.queuedMessages.sessionId, fields: { body } }), - hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + hostInstance: input.hostInstance, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, position } @@ -126,73 +136,63 @@ export async function holdJournalLeftoverSends( clientMessageId, state: 'rejected' as const, ...rejection, - fence, + fence: input.fence, recovered: true as const } try { - await journal.resolveDispatch(reject, hold) + await journal.resolveDispatch(reject, keep) } catch (error) { - if (!hold) { + if (!keep) { failures.push(error) continue } - // Keeping it failed: rejected as before, which loses nothing an older build kept. - console.warn('[journal-open] keeping a send a restart interrupted failed:', { + // 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 sends an earlier host process left queued failed') + throw new AggregateError(failures, 'settling unsent sends failed') } } -/** Where each card of the batch goes: the cards it keeps, the hand-offs' own cards, and cards an - * interrupted earlier run kept, in acceptance order, right before every other card. */ +/** 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 hand-offs' own + * cards 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, - kept: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] + batch: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] ): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { const cards = journal.queuedMessages.list() - const head: (Placed & { earlier?: true })[] = [] - for (const card of cards) { - if ( - card.state === 'waiting' && - card.hostInstance === QUEUED_MESSAGE_HELD_ACROSS_RESTART && - card.carriedFrom === null - ) { - head.push({ - sequence: card.queuedAt?.sequence ?? 0, - move: { messageId: card.messageId, position: 0 }, - earlier: true - }) - } - } - for (const { submission, body } of kept) { - const sequence = submission.acceptedSequence ?? 0 + 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) { - head.push({ sequence, move: { messageId: clientMessageId, position: 0 } }) - } else if (cards.some((card) => card.consumedAs === clientMessageId)) { - head.push({ sequence, move: { consumedAs: clientMessageId, position: 0 } }) + if (body && !earlier.includes(clientMessageId)) { + placed.push({ messageId: clientMessageId, position: 0 }) + } else if (!body && cards.some((card) => card.consumedAs === clientMessageId)) { + placed.push({ consumedAs: clientMessageId, position: 0 }) } } const inHead = (card: (typeof cards)[number]): boolean => - head.some(({ move }) => + 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 - head.sort((a, b) => a.sequence - b.sequence) - const placed = head.map((entry, index) => ({ - ...entry, - move: { ...entry.move, position: anchor - head.length + index } - })) + const first = anchor - earlier.length - placed.length return { - placed: placed.filter((entry) => !entry.earlier).map((entry) => entry.move), - earlier: placed.filter((entry) => entry.earlier).map((entry) => entry.move) + 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 9599dc3240fe..05867436e12e 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,10 +1,10 @@ // 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' -import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from './queued-message-pause' /** Hold waiting drafts from auto-sending. The hold retires with the row: consume * and withdraw clear it in their own UPDATE. Returns how many rows it newly reached. */ @@ -31,22 +31,19 @@ export function holdQueuedMessages( /** Ends a restart's pause: waiting rows another host instance wrote are adopted * into this one, the same fact the pause is derived from, so no second copy * exists. Also clears a per-row 'stopped' hold an earlier build of the queue - * wrote, which this build only ever lifts. `keepHeld` leaves the sends a restart - * kept, which only an action on the queue releases. Returns how many rows it changed. */ + * wrote, which this build only ever lifts. Returns how many rows it changed. */ export function adoptQueuedMessages( db: Database.Database, - input: { sessionId: string; hostInstance: string; keepHeld?: boolean } + input: { sessionId: string; hostInstance: string } ): number { - const kept = input.keepHeld ? QUEUED_MESSAGE_HELD_ACROSS_RESTART : null return Number( db .prepare( `UPDATE queued_messages SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END WHERE session_id = ? AND state = 'waiting' - AND (host_instance <> ? OR hold_reason = 'stopped') - AND (? IS NULL OR host_instance <> ?)` + AND (host_instance <> ? OR hold_reason = 'stopped')` ) - .run(input.hostInstance, input.sessionId, input.hostInstance, kept, kept).changes ?? 0 + .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 ) } 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 7d9058d6b0fa..5695148782fc 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 @@ -24,12 +24,12 @@ import type { AgentSessionJournal } from './journal-store' import { deriveQueuePauses, nextSendableQueuedCard, - QUEUED_MESSAGE_HELD_ACROSS_RESTART, queuePauseHolding, resumableQueuePause } 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' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-p', @@ -518,44 +518,21 @@ describe("a restart's pause", () => { ]) }) - it('a send a restart kept stays held after a person’s turn; adoption leaves it, Resume’s takes it', async () => { + it('adoption and Resume keep a kept card held: only its own Send, Edit or Delete releases it', async () => { const journal = await open() - await journal.queuedMessages.insert({ + const kept = await journal.queuedMessages.insert({ messageId: 'kept', body: message('kept across a restart'), fingerprint: 'fp-kept', - hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART - }) - await journal.queuedMessages.insert({ - messageId: 'foreign', - body: message('another process wrote this'), - fingerprint: 'fp-foreign', - hostInstance: 'proc-0' + hostInstance: 'proc-0', + holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) - const pausesAfterTurn = () => - deriveQueuePauses({ - epoch: journal.epoch, - marks: { latestStop: null, resumedSequence: 0 }, - latestPersonTurnSequence: 9, - cards: journal.queuedMessages.list(), - hostInstance: HOST, - restartEnded: true - }).map((pause) => pause.reason) - expect(pausesAfterTurn()).toEqual(['restarted']) - - expect(await journal.queuedMessages.adopt(HOST, { keepHeld: true })).toBe(true) - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance])).toEqual([ - ['kept', QUEUED_MESSAGE_HELD_ACROSS_RESTART], - ['foreign', HOST] - ]) - expect(pausesAfterTurn()).toEqual(['restarted']) - // Only kept rows left: nothing to adopt, and no write. - const revision = journal.queuedMessages.revision() - expect(await journal.queuedMessages.adopt(HOST, { keepHeld: true })).toBe(false) - expect(journal.queuedMessages.revision()).toBe(revision) - + expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) expect(await journal.queuedMessages.adopt(HOST)).toBe(true) - expect(pausesAfterTurn()).toEqual([]) + 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 () => { @@ -634,6 +611,26 @@ describe('which cards the pauses in force hold', () => { expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') }) + // FIFO: nothing sends past a kept card, and no Resume is offered over it; a card whose own send + // failed is overtaken by the cards behind it. + it('a kept card holds every card behind it until the person acts on it; a send_failed one does not', () => { + 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)).toBeNull() + expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() + // A dead process's card behind it is paused, and Resume still has nothing it would send. + const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] + expect(pausesOver(restarted, 0).map((pause) => pause.reason)).toEqual(['restarted']) + expect(resumableQueuePause(pausesOver(restarted, 0), restarted)).toBeNull() + + const failed = [card('failed', 1, { holdReason: 'send_failed' }), behind] + expect(nextSendableQueuedCard(pausesOver(failed, 0), failed)).toBe(behind) + }) + 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 6223ed1e9cf3..4eb48cf395a8 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 @@ -6,21 +6,17 @@ // - '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; or a waiting card is a send a restart kept -// (`QUEUED_MESSAGE_HELD_ACROSS_RESTART`), which only Resume, Send now, Edit or Delete releases. +// 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. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' -/** The host instance of a card made from a send accepted before a quit or crash and never handed - * over. Never a live process's id (a UUID), so it reads as another process's card everywhere; no - * later message adopts it, since a surface that cannot show cards would otherwise send it twice. */ -export const QUEUED_MESSAGE_HELD_ACROSS_RESTART = 'held-across-restart' - /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { latestStop: { sequence: number; event: JournalStopEvent } | null @@ -130,13 +126,7 @@ export function deriveQueuePauses(input: { if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { pauses.push({ reason: 'cleared', since: null }) } - const keptAcrossRestart = waiting.some( - (card) => card.hostInstance === QUEUED_MESSAGE_HELD_ACROSS_RESTART - ) - if ( - keptAcrossRestart || - (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) - ) { + if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { // The process that wrote a card is gone: every card waits, whenever it was written. pauses.push({ reason: 'restarted', since: null }) } @@ -177,15 +167,25 @@ export function queuePauseHolding( return pauses.find((pause) => PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) } -/** 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. */ +/** A card that holds every card behind it until the person acts on it: a returned one, or one + * the host kept. A `send_failed` card does not: the cards behind it still send. */ +function blocksCardsBehind(card: QueueCard): boolean { + return ( + card.state === 'returned' || + (card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) + ) +} + +/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a card + * that blocks the rest (`blocksCardsBehind`) or one a pause holds 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. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned' || queuePauseHolding(pauses, card)) { + if (blocksCardsBehind(card) || queuePauseHolding(pauses, card)) { return null } if (card.state === 'waiting' && card.holdReason === null) { @@ -195,15 +195,15 @@ export function nextSendableQueuedCard( return null } -/** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned - * card, which blocks everything after it until the user acts. None otherwise, so its header - * never offers a Resume that sends nothing. */ +/** The pause to PUBLISH: the one holding the first card Resume would send, not behind a card that + * blocks everything after it until the user acts. None otherwise, so its header never offers a + * Resume that sends nothing. */ export function resumableQueuePause( pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] ): DerivedQueuePause | null { for (const card of cards) { - if (card.state === 'returned') { + if (blocksCardsBehind(card)) { return null } const holding = queuePauseHolding(pauses, card) 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 index fc940c69dee4..9916e6fc31b6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-positions.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-positions.ts @@ -1,5 +1,5 @@ // Where a card sits in its queue, for a writer placing cards ahead of the others -// (`journal-leftover-send-hold.ts`). The queue sends in position order. +// (`journal-unsent-send-hold.ts`). The queue sends in position order. import type Database from '../../sqlite/sync-database' 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 c3910e466854..dcce5773c3e4 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' @@ -351,7 +352,7 @@ 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 () => { + it('a restart between consume and handover sends the draft back to waiting, kept for its own Send', async () => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1') @@ -361,10 +362,10 @@ describe('returned transition (D1/N4)', () => { 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. + // The host never sent it, so it waits for the person, as a message the restart kept does. expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason: null, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, hostInstance: 'proc-1', consumedAs: null, returnedReason: 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 225100d02deb..67710e42c819 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 @@ -13,16 +13,23 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + QUEUED_MESSAGE_PAUSED_KEPT, + type QUEUED_MESSAGE_PAUSED_SEND_FAILED +} from '../../../shared/agent-session-queued-message-wire' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredRejectionFact } from './journal-dispatch-reducer' 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 @@ -77,6 +84,7 @@ export function insertQueuedMessage( 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. @@ -86,7 +94,7 @@ export function insertQueuedMessage( 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, @@ -95,6 +103,7 @@ export function insertQueuedMessage( input.fingerprint, input.now, input.hostInstance, + input.holdReason ?? null, input.carriedFrom ?? null, input.queuedAt.epoch, input.queuedAt.sequence @@ -108,7 +117,7 @@ export function insertQueuedMessage( createdAt: input.now, hostInstance: input.hostInstance, state: 'waiting', - holdReason: null, + holdReason: input.holdReason ?? null, returnedReason: null, returnedRejection: null, settledAt: null, @@ -222,8 +231,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, @@ -241,11 +250,15 @@ export function settleRejectedQueuedMessage( ? 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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index b86e25674641..9b72d439eefc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -512,7 +512,7 @@ it('rejects as closed a message a second tab close closed, though that close cou // The second close's own rejection of what is queued fails; its stop is a new ask all the same. const session = host['sessions'].get(SESSION)! - vi.spyOn(session.journal, 'rejectQueuedSubmissions').mockRejectedValueOnce(new Error('disk full')) + vi.spyOn(session.journal, 'resolveDispatch').mockRejectedValueOnce(new Error('disk full')) await expect(host.close(SESSION, 'user-close')).rejects.toThrow() expect(connection.closeCount).toBe(3) expect(await submission(next)).toMatchObject({ dispatchState: 'pending' }) 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 19af6d048d45..888b7e0b9969 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 @@ -13,7 +13,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { - abandonQueuedStructuredAgentSessionMessages, + holdClosedStructuredAgentSessionSends, closeStructuredAgentSessionConversationUnderSerialize, finishOwedStructuredAgentSessionWindDownUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, @@ -157,14 +157,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 2d9c1bdc443e..a658c5daa916 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 @@ -7,12 +7,12 @@ // 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 (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-leftover-send-hold.ts`). Nothing here starts a provider child. +// rest rejected (`journal-unsent-send-hold.ts`). Nothing here starts a provider child. import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { holdJournalLeftoverSends } from '../agent-session-journal/journal-leftover-send-hold' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -25,6 +25,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' 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 @@ -113,9 +114,14 @@ export async function openStructuredAgentSessionConversationJournal( } try { // Before a Stop this open serves can withdraw one: a Stop never withdraws a card. - await holdJournalLeftoverSends(opened.journal, fence) + await holdUnsentSends(opened.journal, { + fence, + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause: 'hostRestarted' } + }) } catch (error) { - // The delivery loop's first step retries it, and hands nothing over until it lands. + // 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, 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 c47668b31c6d..eae76a998197 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 @@ -9,7 +9,8 @@ // 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. A message an -// earlier host process left queued is never handed over: its next open keeps it as a held card. +// 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' @@ -19,7 +20,8 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { holdJournalLeftoverSends } from '../agent-session-journal/journal-leftover-send-hold' +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 { structuredAgentSessionStartFailure, @@ -62,8 +64,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 @@ -155,7 +157,7 @@ export class StructuredAgentSessionDeliveryLoop { await this.deps .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { - // Rows left queued go to the next loop an accept wakes, or are settled by the next open. + // 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', @@ -173,8 +175,13 @@ export class StructuredAgentSessionDeliveryLoop { return this.stop(sessionId) } // 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. - await holdJournalLeftoverSends(session.journal, this.deps.conversationFence(sessionId)) + // 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) @@ -299,7 +306,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 f2f885e07638..ce9a93d57e55 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 { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -67,10 +67,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 4dfe37aa154d..758d9fcf4b38 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 { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, @@ -32,6 +31,7 @@ import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' @@ -61,33 +61,32 @@ type ConversationCloseDeps = Pick & { store: Pick } -/** What is still queued when the chat closes will not be handed over. A quit is not a close: its - * leftovers are kept by the next open (`journal-leftover-send-hold.ts`). Best effort: the next open - * settles 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 + } + ) } /** The wind-down this host owes for the session's child. A live child always owes one, whatever a 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 90e6db2f0208..3fb4f471d1ae 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 @@ -7,7 +7,10 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalHostSendSource, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -61,7 +64,7 @@ export function sendStructuredAgentSessionTurn( * prompt never set it. */ userSend?: true /** Which host-internal path sent it, when not a person (`AgentJournalSubmissionSource`). */ - source?: 'launch' | 'mail' | 'continuation' + source?: AgentJournalHostSendSource beforeRun?: () => void } ): Promise> { 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 10fbac72876d..578e548fb61a 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 @@ -6,8 +6,8 @@ // journal. Send is fail-closed: admission alone cannot prove non-delivery. import type { - AgentJournalMessageItem, - AgentJournalSubmissionSource + AgentJournalHostSendSource, + AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' @@ -69,7 +69,7 @@ export function sendPlan(params: { delivery?: 'queue-if-active' userSend?: true /** Which path sent it; a person's send (`userSend`) is always `person`. */ - source?: Extract + source?: AgentJournalHostSendSource beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -101,7 +101,7 @@ 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. Only a send that - // asked to be queued may get that answer: a direct send a restart kept as a card answers + // 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' 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 b392ceca7619..993dd4c72fa3 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 @@ -779,16 +779,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-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 460348c8b741..26ed2a4c3175 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' @@ -21,7 +22,6 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' -import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from '../agent-session-journal/queued-message-pause' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { queuedMessageFingerprint, @@ -117,12 +117,12 @@ export async function carryQueuedMessagesToClearReplacement( messageId: row.messageId, body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), - // A send a restart kept stays held there too: no later message may release it. - hostInstance: - row.hostInstance === QUEUED_MESSAGE_HELD_ACROSS_RESTART - ? QUEUED_MESSAGE_HELD_ACROSS_RESTART - : structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId + hostInstance: structuredAgentSessionHostInstance(), + carriedFrom: ctx.sessionId, + // 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, { @@ -319,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-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index c24bf0416461..7b85ede6fe92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -4,10 +4,7 @@ import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { - QUEUED_MESSAGE_HELD_ACROSS_RESTART, - type DerivedQueuePause -} from '../agent-session-journal/queued-message-pause' +import type { DerivedQueuePause } from '../agent-session-journal/queued-message-pause' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own @@ -35,9 +32,7 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ /** * Every journal publish: a restart's rows are adopted into this instance once a person's turn * started. The derivation already reads them as lifted; the write keeps that answer when the - * handle reopens (its "since this conversation opened" moves). A send a restart kept is not: a - * person's later message must never release it, since one who cannot see cards may have typed the - * same words again. Bookkeeping: a failure is reported. + * handle reopens (its "since this conversation opened" moves). Bookkeeping: a failure is reported. */ export async function adoptEndedRestartPause( sessionId: string, @@ -48,14 +43,9 @@ export async function adoptEndedRestartPause( const { queuedMessages } = journal const restarted = queuedMessages .list() - .some( - (row) => - row.state === 'waiting' && - row.hostInstance !== hostInstance && - row.hostInstance !== QUEUED_MESSAGE_HELD_ACROSS_RESTART - ) + .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) if (restarted && queuedMessages.restartEnded()) { - await queuedMessages.adopt(hostInstance, { keepHeld: true }) + await queuedMessages.adopt(hostInstance) } } catch (error) { logger.warn("adopting a restart's queued cards after a started turn failed", { @@ -67,7 +57,7 @@ export async function adoptEndedRestartPause( } /** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's - * rows, the sends it kept included. Returns whether the queue was paused. */ + * rows. Returns whether the queue was paused. */ export async function resumeStructuredQueue( journal: Pick, fence: number 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 1fa46616da18..50f3b9ef270e 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-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index b4253800c84b..14a5a49c5755 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' @@ -380,10 +381,10 @@ describe('startup restore of chats still in their per-chat files', () => { state: 'rejected' }) const cards = hostDb() - .prepare('SELECT message_id, host_instance, state FROM queued_messages WHERE session_id = ?') + .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') .all('chat-kept') expect(cards).toEqual([ - { message_id: 'client-chat-kept', host_instance: 'held-across-restart', state: 'waiting' } + { message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts similarity index 69% rename from src/main/native-chat/agent-session-wire/structured-agent-session-leftover-send-hold.test.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 366b6c23977c..87b589630e87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-leftover-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -1,18 +1,21 @@ -// A message the host accepted while the agent was starting, then Orca quit or crashed before handing -// it over: the next open keeps it as a held card at the head of the queue, never sends it on its -// own, and never lets a later message release it. Against the real host, store and journal. +// 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 { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from '../agent-session-journal/queued-message-pause' 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, @@ -28,6 +31,10 @@ import { 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 @@ -90,8 +97,9 @@ describe('a message accepted while the agent starts, then Orca stops', () => { const id = await acceptWhileStarting(sendRequest('hello after restart')) await restart() - expect(await rig.drafts()).toEqual([{ messageId: id, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + 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, @@ -101,14 +109,15 @@ describe('a message accepted while the agent starts, then Orca stops', () => { const [card] = journal().queuedMessages.list() expect(card).toMatchObject({ messageId: id, - hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART, + 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, state: 'waiting' }]) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(rig.dispatch).not.toHaveBeenCalled() }) @@ -128,14 +137,14 @@ describe('a message accepted while the agent starts, then Orca stops', () => { await rig.crashRestartHostProcess() expect(await rig.drafts()).toEqual([ - { messageId: first, state: 'waiting' }, - { messageId: second.envelope.clientOperationId, state: 'waiting' } + { messageId: first, ...KEPT }, + { messageId: second.envelope.clientOperationId, ...KEPT } ]) }) }) -describe('only an action on the queue releases a kept card', () => { - it('a later message is delivered alone; Resume then sends the kept one once', async () => { +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() @@ -148,15 +157,19 @@ describe('only an action on the queue releases a kept card', () => { 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, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(id)).toBeDefined()) + 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: 'host', source: 'queue' }) + expect(handoff).toMatchObject({ origin: 'client', source: 'queue' }) 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 () => { @@ -183,9 +196,75 @@ describe('only an action on the queue releases a kept card', () => { expect(await rig.stop()).toMatchObject({ ok: true }) - expect(await rig.drafts()).toEqual([{ messageId: id, state: 'waiting' }]) + 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. + 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 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 + ) + .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 @@ -195,7 +274,7 @@ describe('the same send arriving again after the restart', () => { const request = sendRequest('sent again by the outbox') const id = await acceptWhileStarting(request) await rig.quitRestartHostProcess() - expect(await rig.drafts()).toEqual([{ messageId: id, state: 'waiting' }]) + 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) @@ -209,8 +288,7 @@ describe('the same send arriving again after the restart', () => { }) 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, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) await new Promise((resolve) => setTimeout(resolve, 100)) expect(rig.dispatch).not.toHaveBeenCalled() @@ -223,7 +301,7 @@ describe('the same send arriving again after the restart', () => { 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, state: 'waiting' }]) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) }) it('a client that asked to queue is told the host holds it', async () => { @@ -235,23 +313,26 @@ describe('the same send arriving again after the restart', () => { ok: true, value: { clientMessageId: id, queued: { messageId: id, state: 'waiting' } } }) - expect(await rig.drafts()).toEqual([{ messageId: id, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(rig.dispatch).not.toHaveBeenCalled() }) }) describe('what is not kept', () => { - it('orchestration mail and a send from no person are rejected, as before', async () => { - const { userSend: _person, ...mail } = sendRequest('mail') - await acceptWhileStarting({ ...mail, source: 'mail' }) - await rig.crashRestartHostProcess() - expect(await rig.drafts()).toEqual([]) - expect(await rig.submission(mail.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'rejected', - ...HOST_RESTARTED - }) - }) + it.each(['mail', 'dispatch'] as const)( + 'an orchestration %s send is rejected, as before', + async (source) => { + const { userSend: _person, ...sent } = sendRequest(source) + await acceptWhileStarting({ ...sent, source }) + await rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([]) + expect(await rig.submission(sent.envelope.clientOperationId)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + source + }) + } + ) 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')) @@ -269,8 +350,8 @@ describe('what is not kept', () => { ) expect(await rig.drafts()).toEqual([]) expect(warned).toHaveBeenCalledWith( - '[journal-open] keeping a send a restart interrupted failed:', - expect.objectContaining({ clientMessageId: id }) + '[journal-hold] keeping an unsent send failed:', + expect.objectContaining({ clientMessageId: id, cause: 'hostRestarted' }) ) }) @@ -294,16 +375,14 @@ describe('what is not kept', () => { // 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, state: 'waiting' }]) - ) + 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 lifts the clear, never the restart', async () => { + 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) @@ -316,10 +395,9 @@ describe('/clear carries a kept card still held', () => { if (!replacementId) { throw new Error('expected a replacement session') } - expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, state: 'waiting' }]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(carried?.queuedMessages.list()[0]?.hostInstance).toBe(QUEUED_MESSAGE_HELD_ACROSS_RESTART) + 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, { @@ -349,11 +427,8 @@ describe('/clear carries a kept card still held', () => { clientMessageId: turnId, providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } }) - await eventually(async () => - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'restarted' }) - ) await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, state: 'waiting' }]) + expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) expect(dispatchedTexts()).toEqual(['first turn after the clear']) }) }) 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 56be3f00d598..5c8513d2bf15 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 @@ -183,7 +183,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: the next open keeps it as a card. + // 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/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index a5a79b6be153..17f4bb30211d 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -239,7 +239,8 @@ export async function sendStructuredWorkerPreamble(args: { expectedRuntimeFence: fence, payloadFingerprint: structuredPointerPayloadFingerprint(args.sessionId, body) }, - body + body, + source: 'dispatch' } ) if (!result.ok) { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index a7e1391a02c1..a5471b1318dc 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -21,7 +21,10 @@ import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/st import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' +import { + QUEUED_MESSAGE_PAUSED_KEPT, + QUEUED_MESSAGE_PAUSED_SEND_FAILED +} from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' @@ -60,6 +63,12 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null "Couldn't send — press Send to retry." ) } + if (card.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT) { + return translate( + 'components.native-chat.queuedMessages.pausedKept', + 'Not sent yet — press Send to send it.' + ) + } return translate('components.native-chat.queuedMessages.paused', 'Paused') case 'behind-returned': return translate( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 916cefba3dea..84d6dc4480aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -442,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([ @@ -473,31 +490,31 @@ describe('NativeChatQueuedMessageList', () => { expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) - // A message a restart kept 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. + // 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 steer chord only when the host queues sends ($queueCapable)', + '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: 'queue-paused' })], - { reason: 'restarted' }, + [card({ messageId: 'kept', hold: 'paused', pausedReason: 'kept' })], + null, queueCapable ) renderList(owner) - fireEvent.focus(screen.getByRole('button', { name: 'Steer' })) - const hint = await screen.findAllByText('Submit without interrupting the model') + 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) - // Resume, Delete and Edit work either way. + // 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: 'Resume' })) - expect(owner.resume).toHaveBeenCalled() + 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 5a107e308b87..3e9b759ecf05 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -23,7 +23,7 @@ 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 restart's kept card shows without it. + // 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 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 995d80247699..9bd6670d1ee0 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') }) + // The host sends nothing past a kept card, so the cards behind it say a message ahead waits. + it('a card behind a kept one waits on it; one behind a send_failed card does not', () => { + 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', 'behind-returned']) + }) + 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-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e9d1f394b9af..e26d92f3b0b9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -3,7 +3,10 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import { + QUEUED_MESSAGE_PAUSED_KEPT, + type AgentSessionQueuedMessage +} from '../../../../shared/agent-session-wire' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' import { structuredAgentSessionEntryAsksToQueue, @@ -23,6 +26,7 @@ export type QueuedMessageCardHold = | 'queue-paused' | 'awaiting-answer' | 'paused' + /** Behind a returned card or a kept one, which the host never sends past. */ | 'behind-returned' | 'returned' @@ -75,7 +79,10 @@ export function projectQueuedMessageCards( : session.hasPendingPrompt ? 'awaiting-answer' : 'turn' - behindReturned = behindReturned || message.state === 'returned' + behindReturned = + behindReturned || + message.state === 'returned' || + message.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT return { messageId: message.messageId, position: message.position, 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 588c1817a3e5..4b4fd4499665 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 @@ -25,8 +25,8 @@ 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 a restart kept — but - * queueing settings and the steer chord would do nothing. */ + /** 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. */ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1fd38350c694..18ccc8da9e8f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17989,6 +17989,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 45afe6f04a07..14b2f6369ca6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15205,6 +15205,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 376d83373bb1..9adcf2244491 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17887,6 +17887,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 84cc645254df..e3c6a2b741ba 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17823,6 +17823,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 1e340fdc8378..714a3df35e93 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17823,6 +17823,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 b0b9759f4fa1..e7bae43a3a0f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17788,6 +17788,7 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", + "pausedKept": "尚未发送。点按发送即可发送。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 7349b705e6eb..6ed091bb88ca 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -427,21 +427,28 @@ export type AgentJournalSubmission = { /** Host-only: who asked for this turn — a person over the client send RPC, or Orca itself. * A person's turn is what ends a Stop's queue pause. */ origin?: 'client' | 'host' - /** Host-only: which path sent it, so a restart knows which leftovers to keep as cards. */ - source?: AgentJournalSubmissionSource + /** Which path sent it, so a restart or a close knows which unsent sends to keep as cards. + * Typed values in `AgentJournalSubmissionSource`; a newer build's value stays as it was written, + * never read as absent. Published with the submission; clients ignore it. */ + source?: string } /** Which path sent a submission: a person's send (typed, or `/compact`), `agent.launch`'s first - * prompt, orchestration mail, a restart continuation, or the queue handing off a card. */ -export type AgentJournalSubmissionSource = 'person' | 'launch' | 'mail' | 'continuation' | 'queue' - -export const AGENT_JOURNAL_SUBMISSION_SOURCES: readonly AgentJournalSubmissionSource[] = [ - 'person', - 'launch', - 'mail', - 'continuation', - 'queue' -] + * prompt, orchestration mail, a worker's dispatch preamble, a restart continuation, or the queue + * handing off a card. */ +export type AgentJournalSubmissionSource = + | 'person' + | 'launch' + | 'mail' + | 'dispatch' + | 'continuation' + | 'queue' + +/** The sources a host-internal send names; a person's is `person`, the queue's `queue`. */ +export type AgentJournalHostSendSource = Extract< + AgentJournalSubmissionSource, + 'launch' | 'mail' | 'dispatch' | 'continuation' +> /** Durable answer to "did my send land?", keyed by client message id. Only an * `accepted` dispatch mints one, and it outlives the journal tail. */ diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index f7659c95873b..db2d2b1c6af4 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 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, and the cards behind it wait for it. */ +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/tests/e2e/cross-version-wire/leftover-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts similarity index 65% rename from tests/e2e/cross-version-wire/leftover-card-downgrade.unit.test.ts rename to tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts index 1f1d6bffc949..269e62077ba2 100644 --- a/tests/e2e/cross-version-wire/leftover-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -8,15 +8,18 @@ import type { } from '../../../src/shared/agent-session-journal-types' 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 { holdJournalLeftoverSends } from '../../../src/main/native-chat/agent-session-journal/journal-leftover-send-hold' -import { QUEUED_MESSAGE_HELD_ACROSS_RESTART } from '../../../src/main/native-chat/agent-session-journal/queued-message-pause' +import { holdUnsentSends } from '../../../src/main/native-chat/agent-session-journal/journal-unsent-send-hold' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A send a restart kept is an ordinary waiting card: a value in the host-instance column and a -// place ahead of the queue, no new state or column. A build from before it must still list it, -// first, under its restart pause, and must still settle a leftover this build's quit left queued. -// The main commit this change branched from; move it to the first release that holds this change. +// 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. 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'), { @@ -35,12 +38,19 @@ function message(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } -type OlderCard = { messageId: string; state: string; position: number; hostInstance: string } +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 @@ -60,6 +70,23 @@ type OlderOpener = { 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( @@ -89,7 +116,7 @@ async function acceptPersonSend( }) } -test('an older build lists a kept card first, held by its restart pause', async () => { +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 { @@ -103,7 +130,11 @@ test('an older build lists a kept card first, held by its restart pause', async await acceptPersonSend(earlier, 'kept') await journals.closeAll() const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - await holdJournalLeftoverSends(reopened, 0) + await holdUnsentSends(reopened, { + fence: 0, + hostInstance: 'host-b', + hold: { cause: 'hostRestarted' } + }) expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ 'kept', 'queued-card' @@ -111,23 +142,34 @@ test('an older build lists a kept card first, held by its restart pause', async await journals.closeAll() const older = await olderOpener() + const nextSendable = await olderNextSendable() try { const downgraded = await older.open({ identity: IDENTITY, stateDirectory: directory }) - expect( - downgraded.queuedMessages.list().map(({ messageId, state, hostInstance }) => ({ + const cards = () => + downgraded.queuedMessages.list().map(({ messageId, state, holdReason }) => ({ messageId, state, - hostInstance + holdReason })) - ).toEqual([ - { messageId: 'kept', state: 'waiting', hostInstance: QUEUED_MESSAGE_HELD_ACROSS_RESTART }, - { messageId: 'queued-card', state: 'waiting', hostInstance: 'host-a' } + 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.queuedMessages.pauses('host-b').map((pause) => pause.reason)).toEqual([ - 'restarted' - ]) 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() } From e066f6e77c0fcd6d1f16224dcafe3471865db052 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:29:14 -0700 Subject: [PATCH 03/22] test(native-chat): the dispatch preamble records its source --- .../orchestration-structured-worker-session.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 c3c69dc15fd2..93785a1e1bc7 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' }) + // Recorded so a restart or a close knows it is the dispatch's, which orchestration re-derives. + it('sends the preamble as the dispatch’s, never a 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]).toMatchObject({ source: 'dispatch' }) + 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 })) From 3fdef59de8e22c8a095bebfb589d5337a8386991 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:02:48 -0700 Subject: [PATCH 04/22] fix(native-chat): skip a kept card like a failed one, and make a quit leave the queue as a crash does - A kept card is held on its own, as a send_failed one is: the queue sends the cards behind it, and the "a message ahead needs attention" caption no longer appears behind it (desktop and phone). - Quit disposes the queue's drain together with delivery, so it mints no hand-off that only the next process could settle. - Only a Send the person asked for (origin client) that a restart or close cut short returns kept; the queue's own hand-off returns where it stood, under the restart's pause, as on main. - Comments that said only a capable host gets cards or the card actions now say the capability gates only queueing a new send. --- .../MobileNativeChatQueuedMessages.test.tsx | 1 - .../MobileNativeChatQueuedMessages.tsx | 4 +- .../mobile-native-chat-controller-contract.ts | 4 +- .../mobile-native-chat-pending-echo.ts | 2 +- ...le-structured-queued-message-cards.test.ts | 22 ++- .../mobile-structured-queued-message-cards.ts | 10 +- ...se-mobile-native-chat-queued-slot.test.tsx | 1 - ...e-structured-agent-session-queued.test.tsx | 7 +- .../use-mobile-structured-agent-session.ts | 2 +- .../journal-dispatch-settlement.test.ts | 25 ++-- .../journal-dispatch-settlement.ts | 19 +-- .../journal-unsent-send-hold.test.ts | 135 ++++++++++-------- .../journal-unsent-send-hold.ts | 17 ++- .../queued-message-pause.test.ts | 16 +-- .../queued-message-pause.ts | 27 ++-- .../queued-message-settlement.ts | 8 +- .../queued-message-store.test.ts | 55 ++++--- .../queued-message-table.ts | 4 +- .../structured-agent-session-host-teardown.ts | 4 +- .../structured-agent-session-host.ts | 1 + ...tructured-agent-session-queued-messages.ts | 11 +- ...red-agent-session-unsent-send-hold.test.ts | 34 +++++ ...structured-agent-session-queued-methods.ts | 4 +- ...uctured-agent-session-queued-cards.test.ts | 6 +- .../structured-agent-session-queued-cards.ts | 11 +- .../use-structured-agent-session.ts | 3 +- ...tructured-agent-session-host-capability.ts | 4 +- .../agent-session-queued-message-wire.ts | 4 +- src/shared/protocol-version.ts | 12 +- 29 files changed, 253 insertions(+), 200 deletions(-) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index d27eed1bafa5..b2929f1a1a57 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -34,7 +34,6 @@ function card( text: `text of ${overrides.messageId}`, state: 'waiting', paused: false, - kept: false, needsAttention: false, caption: null, ...overrides diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 57f2fa410ffe..4b53ee81556d 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 3e0371805306..90ebecb42ebb 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 136043447899..01b8c7d4bc23 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 396f0a92ab86..987d9aeefd48 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -47,7 +47,6 @@ describe('mobileQueuedMessageCards', () => { text: 'body of a', state: 'waiting', paused: false, - kept: false, needsAttention: false, caption: null }) @@ -103,13 +102,14 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) - // A kept card blocks what is behind it, as a returned one does. + // 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, waiting])).toBe(false) + expect(resumable([kept])).toBe(false) + expect(resumable([kept, waiting])).toBe(true) }) - // The host kept it unsent across a restart or a close; nothing behind it sends until it does. - it('captions a kept card as not sent yet, and the cards behind it as waiting on it', () => { + // 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 }), @@ -118,15 +118,9 @@ describe('mobileQueuedMessageCards', () => { [], { pendingPrompt: false } ) - expect( - cards.map(({ caption, kept, needsAttention }) => ({ caption, kept, needsAttention })) - ).toEqual([ - { caption: 'Not sent yet — tap Send to send it', kept: true, needsAttention: false }, - { - caption: 'Waiting — a message ahead needs attention', - kept: false, - needsAttention: false - } + expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([ + { caption: 'Not sent yet — tap Send to send it', needsAttention: false }, + { caption: null, needsAttention: false } ]) }) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index e6b90a2bb3fa..843292e44210 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -20,8 +20,6 @@ export type MobileQueuedMessageCard = { text: string state: 'waiting' | 'returned' paused: boolean - /** Held because the host kept it unsent; the cards behind it wait for it. */ - kept: boolean /** Returned, or its own send failed: the row leads with an alert. */ needsAttention: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ @@ -71,10 +69,10 @@ const QUEUE_PAUSE_LABELS: Readonly> = { } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any - * returned or kept card. The drain stops at either, so cards behind one never go. */ + * returned card. The drain stops at a returned card, so cards behind one never go. */ export function mobileQueueHasResumableCard(cards: readonly MobileQueuedMessageCard[]): boolean { for (const card of cards) { - if (card.state === 'returned' || card.kept) { + if (card.state === 'returned') { return false } if (!card.paused) { @@ -130,19 +128,17 @@ export function mobileQueuedMessageCards( : facts.pendingPrompt ? 'Waiting for your answer' : null - const kept = paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT cards.push({ messageId: draft.messageId, text: queuedMessageBodyText(draft.body), state: draft.state, paused, - kept, needsAttention: draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), caption }) - if (draft.state === 'returned' || kept) { + if (draft.state === 'returned') { behindReturned = true } } diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx index aa3c2be26618..002a567548f6 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -32,7 +32,6 @@ const CARD = { text: 'next', state: 'waiting' as const, paused: false, - kept: false, needsAttention: false, caption: null } 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 b3c1cdbb3fc6..fe7f7f1eb14a 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 @@ -532,7 +532,6 @@ describe('mobile structured queued messages', () => { text: 'text of draft-1', state: 'waiting', paused: false, - kept: false, needsAttention: false, caption: null } @@ -587,10 +586,10 @@ describe('mobile structured queued messages', () => { ) 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: 'Waiting — a message ahead needs attention' } + { messageId: 'behind', caption: null } ]) - // Resume would send nothing past the kept card, so it is not offered. - expect(hook!.queued.pause).toBeNull() + // 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 372dd9fab5e6..fc4873812494 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -58,7 +58,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 } 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 052ee2e6fd08..7c6deabef407 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,10 +10,11 @@ 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', () => { @@ -26,15 +27,17 @@ describe('what a rejection does to the draft it was consumed from', () => { expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false }) }) - // The host keeps what it accepted and never sent: the card waits for the person's own Send. - it('a restart or close before hand-over sends it back to waiting, kept', () => { + // 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)).toEqual({ state: 'waiting', kept: true }) + 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', - kept: true - }) + 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 a41dbc0e1b87..da67c08d8e36 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 @@ -33,20 +33,23 @@ export function consumedSubmissionWasRejected( export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean } /** - * Where no one failed the user, the draft goes back to waiting at its own position. A restart or a - * close that interrupted it before hand-over keeps it held (`kept`) until the person sends it, as - * the host keeps every message it accepted and never sent; a Stop's withdrawal leaves it under the - * Stop's own pause. 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 under the pause. 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 { - const { verdict, kind } = classifyDispatchRejection(rejection) + const { verdict, kind } = classifyDispatchRejection(rejected) if (verdict !== null) { return { state: 'returned' } } - return { state: 'waiting', kept: kind === 'hostRestarted' || kind === 'chatClosed' } + 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-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index e5c739abfeb4..bd590395d681 100644 --- 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 @@ -184,37 +184,45 @@ describe('which sends an earlier host process left unsent are kept', () => { } }) - it('a card’s own hand-off returns its card kept, and makes no second one', async () => { - const journal = await afterRestart(async (earlier) => { - await earlier.queuedMessages.insert({ - messageId: 'card', - body: message('card text'), - fingerprint: fingerprint(message('card text')), - hostInstance: 'proc-1' - }) - await earlier.appendSubmission( - { - clientMessageId: 'handoff', - payloadFingerprint: fingerprint(message('card text')), + // 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'), - fence: 0, - handoverRecorded: true, - origin: 'host', - source: 'queue' - }, - { messageId: 'card', expect: 'waiting', settledByOp: null } - ) - }) - await hold(journal) + fingerprint: fingerprint(message('card text')), + hostInstance: 'proc-1' + }) + await earlier.appendSubmission( + { + clientMessageId: 'handoff', + payloadFingerprint: fingerprint(message('card text')), + body: message('card text'), + fence: 0, + handoverRecorded: true, + origin, + source: 'queue' + }, + { 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: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(journal.queuedMessages.get('handoff')).toBeNull() - }) + 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() @@ -255,39 +263,46 @@ describe('which sends an earlier host process left unsent are kept', () => { }) describe('where kept sends go in the queue', () => { - it('in acceptance order, a returned hand-off among them, all ahead of the cards already waiting', async () => { - const journal = await afterRestart(async (earlier) => { - await earlier.queuedMessages.insert({ - messageId: 'H', - body: message('H'), - fingerprint: fingerprint(message('H')), - hostInstance: 'proc-1' - }) - await earlier.queuedMessages.insert({ - messageId: 'C', - body: message('C'), - fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1' - }) - await accept(earlier, 'A', { origin: 'client', source: 'person' }) - await earlier.appendSubmission( - { - clientMessageId: 'H-handoff', - payloadFingerprint: fingerprint(message('H')), + // 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'), - fence: 0, - handoverRecorded: true, - origin: 'host', - source: 'queue' - }, - { messageId: 'H', expect: 'waiting', settledByOp: null } - ) - await accept(earlier, 'B', { origin: 'client', source: 'person' }) - }) - await hold(journal) + fingerprint: fingerprint(message('H')), + hostInstance: 'proc-1' + }) + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1' + }) + await accept(earlier, 'A', { origin: 'client', source: 'person' }) + await earlier.appendSubmission( + { + clientMessageId: 'H-handoff', + payloadFingerprint: fingerprint(message('H')), + body: message('H'), + fence: 0, + handoverRecorded: true, + origin, + source: 'queue' + }, + { messageId: 'H', expect: 'waiting', settledByOp: null } + ) + await accept(earlier, 'B', { origin: 'client', source: 'person' }) + }) + await hold(journal) - expect(cardOrder(journal)).toEqual(['A', 'H', 'B', 'C']) - }) + 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) => { 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 index e952590f4db1..7757df65b164 100644 --- 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 @@ -28,7 +28,7 @@ export type UnsentSendHold = | { 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, kept (`rejectedDraftSettlement`); + * - 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; * - orchestration mail (the mailbox re-sends it), a dispatch preamble or a restart continuation @@ -56,8 +56,9 @@ export function unsentSendKeptAsCard( /** * 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 a card hand-off's own card, go to the head of the queue in the - * order they were accepted, behind the cards an earlier settlement kept. Throws once every row was + * 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( @@ -163,8 +164,8 @@ export async function holdUnsentSends( } /** 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 hand-offs' own - * cards in the order they were accepted. A kept card's `queuedAt` cannot order it against + * 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, @@ -179,7 +180,11 @@ function headOfQueuePositions( const { clientMessageId } = submission if (body && !earlier.includes(clientMessageId)) { placed.push({ messageId: clientMessageId, position: 0 }) - } else if (!body && cards.some((card) => card.consumedAs === clientMessageId)) { + } 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 }) } } 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 5695148782fc..ed61812bcecf 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 @@ -611,24 +611,20 @@ describe('which cards the pauses in force hold', () => { expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') }) - // FIFO: nothing sends past a kept card, and no Resume is offered over it; a card whose own send - // failed is overtaken by the cards behind it. - it('a kept card holds every card behind it until the person acts on it; a send_failed one does not', () => { + // 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)).toBeNull() + expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() - // A dead process's card behind it is paused, and Resume still has nothing it would send. const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] - expect(pausesOver(restarted, 0).map((pause) => pause.reason)).toEqual(['restarted']) - expect(resumableQueuePause(pausesOver(restarted, 0), restarted)).toBeNull() - - const failed = [card('failed', 1, { holdReason: 'send_failed' }), behind] - expect(nextSendableQueuedCard(pausesOver(failed, 0), failed)).toBe(behind) + expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() + expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') }) it("a /clear's pause that holds nothing never hides a restart's", () => { 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 4eb48cf395a8..bcb0f43ccf07 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 @@ -12,7 +12,6 @@ // continuation, a launch prompt and the queue's own drain are `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -167,25 +166,15 @@ export function queuePauseHolding( return pauses.find((pause) => PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) } -/** A card that holds every card behind it until the person acts on it: a returned one, or one - * the host kept. A `send_failed` card does not: the cards behind it still send. */ -function blocksCardsBehind(card: QueueCard): boolean { - return ( - card.state === 'returned' || - (card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) - ) -} - -/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a card - * that blocks the rest (`blocksCardsBehind`) or one a pause holds 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. */ +/** 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. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (blocksCardsBehind(card) || queuePauseHolding(pauses, card)) { + if (card.state === 'returned' || queuePauseHolding(pauses, card)) { return null } if (card.state === 'waiting' && card.holdReason === null) { @@ -195,15 +184,15 @@ export function nextSendableQueuedCard( return null } -/** The pause to PUBLISH: the one holding the first card Resume would send, not behind a card that - * blocks everything after it until the user acts. None otherwise, so its header never offers a - * Resume that sends nothing. */ +/** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned + * card, which blocks everything after it until the user acts. None otherwise, so its header + * never offers a Resume that sends nothing. */ export function resumableQueuePause( pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] ): DerivedQueuePause | null { for (const card of cards) { - if (blocksCardsBehind(card)) { + if (card.state === 'returned') { return null } const holding = queuePauseHolding(pauses, card) 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 8d2e9ec0acce..029bdb74e6f3 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 dcce5773c3e4..782d29f25c86 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 @@ -88,7 +88,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( @@ -97,7 +102,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, @@ -352,25 +358,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, kept for its own Send', 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) - ) - // The host never sent it, so it waits for the person, as a message the restart kept does. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, - 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() 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 67710e42c819..e72f58e43a7c 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 @@ -241,10 +241,12 @@ 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 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 3c0258e040d5..d0c1b4bcd93b 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 @@ -121,8 +121,8 @@ async function tearDownStructuredAgentSessionHost(input: { const entries = [...input.sessions.entries()].filter( ([sessionId]) => !input.retainSessionIds?.has(sessionId) ) - // Quit settles nothing still queued: delivery is already disposed, so nothing hands it over now, - // and the next open keeps a person's message as a held card. `allSettled`, so one failed close + // 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 ([, session]) => { 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 44a576e2f781..f5b7a41cf100 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 @@ -257,6 +257,7 @@ export class StructuredAgentSessionHost { // type, and this file has no line budget left for the import. async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.conversationDelivery.dispose() + this.queued.drain.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), idleSweep: this.lifetime, 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 55e2a26ed1a5..a5962ded911f 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 @@ -267,12 +267,19 @@ 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. */ + dispose(): void { + this.disposed = true + } + schedule(sessionId: string): void { const journal = this.deps.sessions.get(sessionId)?.journal - if (!journal || journal.isReadOnly) { + if (this.disposed || !journal || journal.isReadOnly) { return } // Cheap pre-check so token streams do not pay a serialized step per delta. @@ -315,7 +322,7 @@ export class StructuredAgentSessionQueuedMessageDrain { private async step(sessionId: string): Promise { const session = this.deps.sessions.get(sessionId) - if (!session || session.journal.isReadOnly) { + if (this.disposed || !session || session.journal.isReadOnly) { return } await this.deps.flushStreamedEvents(sessionId) 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 index 87b589630e87..249f03987bed 100644 --- 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 @@ -233,6 +233,40 @@ describe('only an action on the card releases a kept card', () => { }) }) +// 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() + } + ) +}) + // 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', () => { 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 a4b68ccbcd3a..259ee2dbe148 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/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 9bd6670d1ee0..4dc58abf4908 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,8 +159,8 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) - // The host sends nothing past a kept card, so the cards behind it say a message ahead waits. - it('a card behind a kept one waits on it; one behind a send_failed card does not', () => { + // 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' }), @@ -171,7 +171,7 @@ describe('queued message cards', () => { [], IDLE ) - expect(cards.map((card) => card.hold)).toEqual(['paused', 'turn', 'paused', 'behind-returned']) + expect(cards.map((card) => card.hold)).toEqual(['paused', 'turn', 'paused', 'turn']) }) it('steers the newest card', () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e26d92f3b0b9..e9d1f394b9af 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -3,10 +3,7 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type AgentSessionQueuedMessage -} from '../../../../shared/agent-session-wire' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' import { structuredAgentSessionEntryAsksToQueue, @@ -26,7 +23,6 @@ export type QueuedMessageCardHold = | 'queue-paused' | 'awaiting-answer' | 'paused' - /** Behind a returned card or a kept one, which the host never sends past. */ | 'behind-returned' | 'returned' @@ -79,10 +75,7 @@ export function projectQueuedMessageCards( : session.hasPendingPrompt ? 'awaiting-answer' : 'turn' - behindReturned = - behindReturned || - message.state === 'returned' || - message.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT + behindReturned = behindReturned || message.state === 'returned' return { messageId: message.messageId, position: message.position, 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 96ad805560d9..2e4e1cdb4c74 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 @@ -107,8 +107,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( 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 2b1a76d2c5b4..97c15f9fd079 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -93,8 +93,8 @@ export function useStructuredAgentSessionHostStopsConversation( ) } -/** 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-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index db2d2b1c6af4..6a2829476d6f 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -7,8 +7,8 @@ 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 -/** A 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, and the cards behind it wait for it. */ +/** 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 = diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5d3217f00f3d..1522cce8ced6 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -182,14 +182,16 @@ export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = 'agent-session.accepted-send.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 From 1041d54687f7819425819ef8c3653b6dbaee722f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:03:54 -0700 Subject: [PATCH 05/22] refactor(native-chat): one dispose gate in the queue drain's step --- .../structured-agent-session-queued-messages.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 a5962ded911f..28dc141d25e6 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 @@ -272,14 +272,15 @@ export class StructuredAgentSessionQueuedMessageDrain { 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. */ + * quit leaves the cards exactly as a crash does. Read in the step, which a step already + * scheduled also passes through. */ dispose(): void { this.disposed = true } schedule(sessionId: string): void { const journal = this.deps.sessions.get(sessionId)?.journal - if (this.disposed || !journal || journal.isReadOnly) { + if (!journal || journal.isReadOnly) { return } // Cheap pre-check so token streams do not pay a serialized step per delta. From 0dd6fe249182319bd98f4603a22a7b8f1d487d16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:04:52 -0700 Subject: [PATCH 06/22] test(native-chat): the downgrade test names the older build's /clear exception --- tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 269e62077ba2..d943e471efec 100644 --- a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -17,7 +17,8 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // 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. The main commit this change branched from, which has the queue; move it to the +// 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' From 272bfa81172412b9964c81db61466fb14323203e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:21:35 -0700 Subject: [PATCH 07/22] fix(native-chat): re-check the drain's quit gate right before it appends a hand-off A drain step already past its first check when quit begins no longer makes a hand-off. Adds regression tests for that, for Send now on an ordinary card cut short by a quit, and for the open-time repair deriving kept from the hand-off's origin; fixes the pause and settlement comments that called a kept card one the queue never passes. --- .../journal-dispatch-settlement.ts | 3 +- .../queued-message-pause.ts | 9 ++- .../queued-message-store.test.ts | 40 ++++++++++ ...tructured-agent-session-queued-messages.ts | 6 +- ...red-agent-session-unsent-send-hold.test.ts | 74 +++++++++++++++++++ 5 files changed, 124 insertions(+), 8 deletions(-) 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 da67c08d8e36..6b3e53f36b37 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 @@ -37,7 +37,8 @@ export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' * 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 under the pause. A returned card would block the drafts + * 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. */ 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 bcb0f43ccf07..d2270d8afa7f 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 @@ -149,8 +149,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 @@ -167,8 +167,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-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 782d29f25c86..1045a42acb60 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 @@ -534,6 +534,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-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 28dc141d25e6..94fbb1eeea48 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 @@ -272,8 +272,8 @@ export class StructuredAgentSessionQueuedMessageDrain { 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 in the step, which a step already - * scheduled also passes through. */ + * 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 } @@ -347,7 +347,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-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 249f03987bed..19f8fcd79eef 100644 --- 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 @@ -267,6 +267,80 @@ describe('cards queued behind a working turn, then Orca stops', () => { ) }) +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()) + } + + it('a drain step already running when quit begins makes no hand-off', async () => { + 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 flush = host.flushStreamedEvents + 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 only the drain step's own flush, past its first dispose check, until quit has begun. + host.flushStreamedEvents = async (sessionId: string) => { + if ( + !blocked && + (new Error('which caller flushes').stack ?? '').includes('QueuedMessageDrain.step') + ) { + blocked = true + reached() + await held + } + return flush(sessionId) + } + rig.finishCompact() + await inStep + const quitting = host.flushAllStreamedEvents() + release() + await quitting + host.flushStreamedEvents = flush + 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', () => { From 5e6ea970f346211898ee8c12a614e5e16125d91b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:38:40 -0700 Subject: [PATCH 08/22] fix(native-chat): a card held on its own starts no restart pause for the others After a second restart a kept (or send_failed) card is another process's, but it waits for its own Send, so it no longer pauses every other card under "Queue paused because Orca restarted". --- .../queued-message-pause.test.ts | 9 +++++++++ .../queued-message-pause.ts | 10 +++++++--- ...ured-agent-session-unsent-send-hold.test.ts | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) 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 ed61812bcecf..ade7e6ff671e 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 @@ -627,6 +627,15 @@ describe('which cards the pauses in force hold', () => { 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 d2270d8afa7f..e6df82144b2f 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,8 @@ // 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. @@ -125,7 +125,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 }) } 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 index 19f8fcd79eef..0da9600bd706 100644 --- 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 @@ -201,6 +201,24 @@ describe('only an action on the card releases a kept card', () => { }) // 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() From 40e5c8fc27796afc7fd4efc3d31e6cc536650d6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:28:02 -0700 Subject: [PATCH 09/22] fix(native-chat): record on a kept send which card holds it, so no trace survives an Edit or Delete The rejection row of a send the host kept as a card now names that card (`keptAsQueuedMessageId`), in the same transaction that writes the card, and the fold publishes it on the submission. The shared projection draws such a send only as its card: once the card is sent, edited or deleted, neither the send nor the sending desktop's local copy of it shows. --- .../journal-dispatch-reducer.ts | 7 ++ .../journal-row-builders.ts | 3 + .../journal-row-schema.ts | 3 + .../journal-store-contracts.ts | 2 +- .../journal-unsent-send-hold.test.ts | 14 +++- .../journal-unsent-send-hold.ts | 40 +++++----- ...red-agent-session-unsent-send-hold.test.ts | 46 +++++++++++ src/shared/agent-session-journal-schemas.ts | 3 +- src/shared/agent-session-journal-types.ts | 4 + ...d-agent-session-message-projection.test.ts | 80 +++++++++++++++++++ ...ctured-agent-session-message-projection.ts | 13 ++- 11 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 src/shared/structured-agent-session-message-projection.test.ts 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 89722959799d..8559b056dc3d 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 @@ -30,6 +30,13 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } + if ( + row.state === 'rejected' && + typeof row.keptAsQueuedMessageId === 'string' && + row.keptAsQueuedMessageId.length > 0 + ) { + submission.keptAsQueuedMessageId = row.keptAsQueuedMessageId + } submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts 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 fb5b37874ccc..627262664050 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 @@ -111,6 +111,9 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...(input.state === 'rejected' && input.keptAsQueuedMessageId !== undefined + ? { keptAsQueuedMessageId: input.keptAsQueuedMessageId } + : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), ...(input.recovered ? { recovered: input.recovered } : {}), ...(input.state === 'pending' ? { turnScope: input.turnScope } : {}) 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 085288f1b7dd..3e6f820eb36e 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 @@ -154,6 +154,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 } /** An item mutation may name its own producer, because one batch can CREATE 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 a5932676ade4..b49f0b9d6a35 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 @@ -41,7 +41,7 @@ export type ResolveDispatchInput = { | { state: 'pending'; turnScope: AgentJournalTurnScope } /** `reason` is what released clients print, `rejection` what newer ones read: both from * `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ state: 'rejected'; keptAsQueuedMessageId?: string } & AgentJournalDispatchRejection) | { state: 'unknown'; reason?: string | null } ) 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 index bd590395d681..3b549d3ddc52 100644 --- 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 @@ -132,16 +132,23 @@ describe('which sends an earlier host process left unsent are kept', () => { 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(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(journal.queuedMessages.get(id)).toMatchObject({ + 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: journal.submission(id)?.acceptedSequence } + queuedAt: { epoch: 'epoch-1', sequence: reopened.submission(id)?.acceptedSequence } }) } }) @@ -181,6 +188,7 @@ describe('which sends an earlier host process left unsent are kept', () => { 'no-origin' ]) { expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(journal.submission(id)).not.toHaveProperty('keptAsQueuedMessageId') } }) 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 index 7757df65b164..9ba694c10abb 100644 --- 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 @@ -109,26 +109,26 @@ export async function holdUnsentSends( 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 = - body || moves.length > 0 + card || moves.length > 0 ? (db) => { journal.queuedMessages.holdInTransaction(db, { - card: - body && position !== undefined - ? { - messageId: clientMessageId, - body, - fingerprint: structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: journal.queuedMessages.sessionId, - fields: { body } - }), - hostInstance: input.hostInstance, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, - queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, - position - } - : null, + 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, + queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, + position: card.position + } + : null, positions: moves }) } @@ -141,7 +141,11 @@ export async function holdUnsentSends( recovered: true as const } try { - await journal.resolveDispatch(reject, keep) + // 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) 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 index 0da9600bd706..ae148565fa82 100644 --- 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 @@ -9,6 +9,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' 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' @@ -190,6 +191,49 @@ describe('only an action on the card releases a kept card', () => { 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 + ).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() @@ -475,6 +519,8 @@ describe('what is not kept', () => { }) ) 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' }) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01c..8fdd8fda7441 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -330,7 +330,8 @@ export const AgentJournalSubmissionSchema = z.object({ handedOverAt: z.number().optional(), rejection: FailureFact.optional(), // Listed, or the parse strips it: this schema drops unknown keys. - queuedMessageId: z.string().min(1).optional() + queuedMessageId: z.string().min(1).optional(), + keptAsQueuedMessageId: z.string().min(1).optional() }) export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 6ed091bb88ca..263c8c50e49a 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -431,6 +431,10 @@ export type AgentJournalSubmission = { * Typed values in `AgentJournalSubmissionSource`; a newer build's value stays as it was written, * never read as absent. Published with the submission; clients ignore it. */ source?: 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 } /** Which path sent a submission: a person's send (typed, or `/compact`), `agent.launch`'s first 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 000000000000..39e18717e139 --- /dev/null +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -0,0 +1,80 @@ +// 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' + +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])).toEqual([]) + // A rejection with no card keeps its local copy, marked not sent. + expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [rejected()])).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] + ) + 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 5515b6478a22..4647296dc081 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -15,7 +15,18 @@ export function projectStructuredAgentSessionMessages( submissions: readonly AgentJournalSubmission[], projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { - const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) + // A send the host kept as a card is drawn only as that card, its local copy included: once the + // card is sent, edited or deleted, nothing of the original send shows. + const keptAsCard = new Set( + submissions.flatMap((submission) => + submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined + ? [submission.clientMessageId] + : [] + ) + ) + const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions).filter( + (entry) => !keptAsCard.has(entry.clientMessageId) + ) // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. const rejected = new Set( submissions From 6ce6c1e08314266c26d2221fbd6b248fea471da9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:28:40 -0700 Subject: [PATCH 10/22] chore(native-chat): leave the unused submission schema as main has it No client parses published submissions with it (they arrive as typed frames, and the host's history pages carry the field, as the Edit/Delete test reads); listing the field put the file over its line budget. --- src/shared/agent-session-journal-schemas.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 8fdd8fda7441..035bb7d1d01c 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -330,8 +330,7 @@ export const AgentJournalSubmissionSchema = z.object({ handedOverAt: z.number().optional(), rejection: FailureFact.optional(), // Listed, or the parse strips it: this schema drops unknown keys. - queuedMessageId: z.string().min(1).optional(), - keptAsQueuedMessageId: z.string().min(1).optional() + queuedMessageId: z.string().min(1).optional() }) export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { From 706315504e3e40a005cefe6e4de22a3d9838fb99 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:45:43 -0700 Subject: [PATCH 11/22] fix(native-chat): retire a kept send's local copy instead of only hiding it The outbox reconcile and the send disposition drop an entry whose submission the host rejected as kept as a card, as they already do for a Stop's withdrawal, so the copy never comes back as "Not sent / Retry" once the submission falls out of the loaded page. The projection reads the reconciled outbox, so its separate filter goes. --- ...ent-session-outbox.queue-delivery.test.tsx | 81 ++++++++++++++++++- ...ctured-agent-session-message-projection.ts | 13 +-- src/shared/structured-agent-session-outbox.ts | 7 ++ ...ructured-agent-session-send-disposition.ts | 8 ++ 4 files changed, 96 insertions(+), 13 deletions(-) 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 cfe2ded5a75d..720a2ad41388 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,7 +29,9 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' -import { readOutbox } from './structured-agent-session-outbox-storage' +import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -238,6 +240,83 @@ describe('outbox queue delivery selection', () => { } ) + // 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({ + 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({ + 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/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 4647296dc081..5515b6478a22 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -15,18 +15,7 @@ export function projectStructuredAgentSessionMessages( submissions: readonly AgentJournalSubmission[], projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { - // A send the host kept as a card is drawn only as that card, its local copy included: once the - // card is sent, edited or deleted, nothing of the original send shows. - const keptAsCard = new Set( - submissions.flatMap((submission) => - submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined - ? [submission.clientMessageId] - : [] - ) - ) - const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions).filter( - (entry) => !keptAsCard.has(entry.clientMessageId) - ) + const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. const rejected = new Set( submissions diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index d7ee2f59d3dd..d08e935a856e 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -222,6 +222,13 @@ export function reconcileStructuredAgentSessionOutbox( ) { 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 === 'pending') { if (entry.state === 'dispatching') { return [entry] diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index f717e54f2d43..0809b14b58ca 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -267,6 +267,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 + } + } if (submission.dispatchState === 'rejected') { return { entries: replaceEntryState( From 9c8c818e33b0af997325463af2e94b4d299c3d77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:54:44 -0700 Subject: [PATCH 12/22] test(native-chat): move the queued-message rig's scripted provider into its own fixture The rig fixture grew past the 300-line limit once main's changes merged in. --- ...ueued-message-rig-provider.test-fixture.ts | 119 ++++++++++++++++++ ...session-queued-message-rig.test-fixture.ts | 107 ++-------------- 2 files changed, 132 insertions(+), 94 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts 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 000000000000..85979b2bedf7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -0,0 +1,119 @@ +// 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 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: { provider: 'codex' as const, threadId: 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 2e8c0a01c525..02e7c4bd9c25 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,19 +1,16 @@ -// 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' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' 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 { @@ -27,6 +24,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 { + createQueuedRigProvider, + type QueuedRigProviderOptions +} from './structured-agent-session-queued-message-rig-provider.test-fixture' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -36,79 +37,22 @@ 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({ 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: { provider: 'codex' as const, threadId: 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', @@ -252,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 @@ -329,8 +248,8 @@ export async function createQueuedMessageTestRig( closeSession, awaitStarted, compact, - finishCompact, - providerEvents, + finishCompact: provider.finishCompact, + providerEvents: provider.providerEvents, envelope, send, stop, From 59efcc2551b0aea7a3a4b20e58b7438dd1be370b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:48:30 -0700 Subject: [PATCH 13/22] fix(native-chat): hide a kept send by its own record, not by its card still existing The transcript hid a rejected send while a queued card held it under its id, so the card's Edit or Delete brought back a "Not sent" row. It now reads the send's own keptAsQueuedMessageId, which the host records with the rejection, and the live card list is no longer threaded to the transcript or the delivery notices. --- ...red-agent-session-unsent-send-hold.test.ts | 7 +- ...atMessageList.provider-retry-runs.test.tsx | 4 +- ...iveChatMessageList.unsent-message.test.tsx | 6 +- .../NativeChatStructuredSession.tsx | 1 - .../native-chat-rail-outline-parity.test.ts | 4 +- ...ructured-agent-session-delivery-notices.ts | 8 +- ...sion-message-projection.older-host.test.ts | 3 +- ...n-message-projection.recorded-copy.test.ts | 3 +- ...ssage-projection.rejected-in-place.test.ts | 89 ++++++++++--------- ...d-agent-session-message-projection.test.ts | 15 ++-- ...ctured-agent-session-message-projection.ts | 8 +- ...red-agent-session-transcript-order.test.ts | 4 +- ...ed-agent-session-delivery-notices.test.tsx | 3 - ...ructured-agent-session-delivery-notices.ts | 15 +--- ...structured-agent-session-messages.test.tsx | 18 ++-- .../use-structured-agent-session-messages.ts | 7 +- ...ent-session-outbox.rejected-reply.test.tsx | 4 +- ...tured-agent-session.rejected-card.test.tsx | 17 ++-- .../use-structured-agent-session.ts | 5 +- ...d-agent-session-message-projection.test.ts | 15 ++-- ...ctured-agent-session-message-projection.ts | 12 +-- 21 files changed, 107 insertions(+), 141 deletions(-) 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 index 1b84ce6634e4..030c00f8c677 100644 --- 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 @@ -225,7 +225,9 @@ describe('only an action on the card releases a kept card', () => { const shown = projectStructuredAgentSessionMessages( page.page.items, [lingering], - page.page.submissions + 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 @@ -427,7 +429,8 @@ describe('a message accepted while the agent starts, then the chat closes', () = const texts = projectStructuredAgentSessionMessages( page.page.items, [], - page.page.submissions + page.page.submissions, + { rejectedInPlace: true } ) .flatMap((shown) => shown.blocks.map((block) => ('text' in block ? block.text : ''))) .join('|') 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 db59afdb19db..eb71e3e6b541 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 ( {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -139,7 +137,7 @@ function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutbo return ( , - /** 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 ): ReadonlyMap { @@ -188,11 +186,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 7cc13a0a4eb6..0c5d273bc54b 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 84e10ca18b7b..c27204b91f53 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 4e7577052256..4d94622cfa99 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('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('kept'), text: 'kept text', unsent: true } + { 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 bd99e8f8cbbf..1108d468739b 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 f527442c2b15..cb620ed72d89 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-transcript-order.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts index 3b12ed7b3dcb..aa774f7fbf3d 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 28ef88564b90..f1cd44528a7b 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 26cdd00fb81f..bab5f6a32f0f 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 @@ -18,11 +18,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(() => { @@ -50,19 +49,9 @@ export function useStructuredAgentSessionDeliveryNotices(args: { rejectionRows, startFailures, failedHere, - queuedMessageIds, commandItemIds ), - [ - outbox, - agentName, - retry, - rejectionRows, - startFailures, - failedHere, - queuedMessageIds, - commandItemIds - ] + [outbox, agentName, retry, rejectionRows, startFailures, failedHere, commandItemIds] ) // A submission batch rebuilds the map; one that says the same keeps the old, so no row re-renders. const previousRef = useRef(notices) 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 419e6a6cae9b..68e388de58c5 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 af38cda41df1..f965c2d3eaa3 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.rejected-reply.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx index bfebc877b2c8..5e736e1dcfe2 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.rejected-card.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx index b37350cb39dd..025aba64fb8b 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 aee3c51ab718..3360abdcc7d0 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 @@ -166,8 +166,7 @@ export function useStructuredAgentSession(args: { const messages = useStructuredAgentSessionMessages( transportState.journalItems, transcriptOutbox, - transportState.submissions, - queuedMessageIds + transportState.submissions ) const queuedController = useStructuredAgentSessionQueuedMessages({ enabled: queueCapable && transportState.fence !== null, @@ -217,8 +216,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/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts index 39e18717e139..c0829aa02b63 100644 --- a/src/shared/structured-agent-session-message-projection.test.ts +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -8,6 +8,8 @@ import { createStructuredAgentSessionOutboxEntry } from './structured-agent-sess 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 { @@ -50,11 +52,13 @@ 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])).toEqual([]) + expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [kept], DESKTOP)).toEqual( + [] + ) // A rejection with no card keeps its local copy, marked not sent. - expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [rejected()])).toEqual([ - expect.objectContaining({ id: agentJournalSubmissionKey(KEPT_ID), unsent: true }) - ]) + 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. @@ -71,7 +75,8 @@ describe('a send kept as a card', () => { const shown = projectStructuredAgentSessionMessages( [userItem(KEPT_ID, 'the kept words', 1), userItem('client-edited', 'the edited words', 2)], [lingeringCopy], - [kept, edited] + [kept, edited], + DESKTOP ) expect(shown).toHaveLength(1) expect(shown[0]).toMatchObject({ blocks: [{ text: 'the edited words' }] }) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 57a2f6fe15d6..6d656ea560d1 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() From c647cab070103276082e07e2f5af6ff31418dac6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:56:29 -0700 Subject: [PATCH 14/22] refactor(native-chat): move a sent message's row writes into their own journal collaborator The journal store went past its line limit once main's ledger receipt joined this branch's transaction hook. The submission and dispatch-transition writes, and what commits in their transaction, now live in JournalSubmissionWriter; the store's methods delegate to it unchanged. --- .../journal-store-collaborators.ts | 8 +++ .../agent-session-journal/journal-store.ts | 50 +++++--------- .../journal-submission-writer.ts | 67 +++++++++++++++++++ 3 files changed, 93 insertions(+), 32 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-writer.ts 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 ba8796964692..50ad29456e94 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 @@ -18,6 +18,7 @@ import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-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' @@ -57,6 +58,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + submissionWriter: JournalSubmissionWriter queuedMessages: JournalQueuedMessages stopMarks: JournalStopMarks /** Restores the store's state from disk. Owned here because it needs the same @@ -117,6 +119,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.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 7f6b21a578a4..0f45382c19a9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -40,11 +40,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, @@ -57,14 +53,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, JournalRowTransactionHook } from './journal-row-writer' -import type { 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' @@ -92,6 +92,7 @@ export class AgentSessionJournal { private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender + private readonly submissionWriter: JournalSubmissionWriter private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ readonly queuedMessages: JournalQueuedMessages @@ -143,6 +144,7 @@ export class AgentSessionJournal { this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.submissionWriter = collaborators.submissionWriter this.queuedMessages = collaborators.queuedMessages this.stopMarks = collaborators.stopMarks this.restore = collaborators.restore @@ -332,37 +334,21 @@ export class AgentSessionJournal { return this.lifecycleBatchAppender.append(input) } - /** - * 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.providerHandle, 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. - * `hook` runs in the row's transaction: it commits with the row, or rolls it back by throwing. - */ - resolveDispatch(input: ResolveDispatchInput, hook?: JournalRowTransactionHook) { - const build = journalDispatchRowBuilder(() => this.state, input) - return this.rowWriter.append(build, hook) + /** 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-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts new file mode 100644 index 000000000000..1e6fc613b8b1 --- /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.providerHandle, 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) + } +} From 1c322e2bd971eb0214cac6f8e4938cecb7de26f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:02:02 -0700 Subject: [PATCH 15/22] fix(native-chat): list a kept send's card id in the submission schema The schema drops keys it does not list, so a reader that kept a parsed submission would lose keptAsQueuedMessageId and source, both persisted with the row. The submission schema and the failure fact it shares with item bodies move to their own modules, with room for both fields. --- .../journal-submission-queued-link.test.ts | 2 +- ...structured-conversation-compaction.test.ts | 6 +-- .../agent-session-failure-fact-schema.ts | 9 ++++ .../agent-session-journal-schemas.test.ts | 4 +- src/shared/agent-session-journal-schemas.ts | 42 ++---------------- ...-session-journal-submission-schema.test.ts | 43 +++++++++++++++++++ ...agent-session-journal-submission-schema.ts | 38 ++++++++++++++++ 7 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 src/shared/agent-session-failure-fact-schema.ts create mode 100644 src/shared/agent-session-journal-submission-schema.test.ts create mode 100644 src/shared/agent-session-journal-submission-schema.ts 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 fc9431646158..14b83069414f 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-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 22766f28a416..a4d6c0c0cf1f 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/shared/agent-session-failure-fact-schema.ts b/src/shared/agent-session-failure-fact-schema.ts new file mode 100644 index 000000000000..32f59461fb64 --- /dev/null +++ b/src/shared/agent-session-failure-fact-schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +/** Open like a dispatch `state`: a kind, audience or refusal detail a newer host writes must not + * turn the row malformed; the fact reader is where an unplaceable one is dropped. */ +export const AgentSessionFailureFactSchema = z.object({ + kind: z.string().min(1), + detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), + refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() +}) diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 594d6a04b11c..10dac12611b5 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 035bb7d1d01c..0890827c95f6 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -15,12 +15,12 @@ import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' +import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' import type { AgentJournalItemBody, AgentJournalMessageItem, AgentJournalResolution, - AgentJournalRenderItem, - AgentJournalSubmission + AgentJournalRenderItem } from './agent-session-journal-types' const BoundedPayload = z.object({ @@ -201,14 +201,6 @@ const ThreadGoalState = z.union([ z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) ]) -/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row - * malformed; the fact reader is where an unplaceable one is dropped. */ -const FailureFact = z.object({ - kind: z.string().min(1), - detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), - refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() -}) - export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -262,7 +254,7 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ .optional(), providerFrame: ProviderFrame.optional(), threadGoal: ThreadGoalState.optional(), - failure: FailureFact.optional() + failure: AgentSessionFailureFactSchema.optional() }), z.object({ kind: z.literal('turn'), @@ -316,23 +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(), - recovered: z.literal(true).optional(), - handoverRecorded: z.literal(true).optional(), - handedOverAt: z.number().optional(), - rejection: FailureFact.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 } @@ -354,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. */ @@ -367,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 000000000000..2afe0d8856ea --- /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: 'person', + 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 000000000000..d53eace2c7c9 --- /dev/null +++ b/src/shared/agent-session-journal-submission-schema.ts @@ -0,0 +1,38 @@ +// 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 { 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(), + recovered: z.literal(true).optional(), + handoverRecorded: z.literal(true).optional(), + handedOverAt: z.number().optional(), + rejection: AgentSessionFailureFactSchema.optional(), + queuedMessageId: z.string().min(1).optional(), + source: z.string().min(1).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 +> From 4a4d5b5d9d0ef44a8127d5c372fa0a9af971e5e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:06:33 -0700 Subject: [PATCH 16/22] test(native-chat): match the transcript and outbox hook signatures main and the swap changed --- .../NativeChatMessageList.tool-stream-cost.test.tsx | 2 +- ...use-structured-agent-session-outbox.queue-delivery.test.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx index d8a9379ca190..a77d2907b6a2 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx @@ -32,7 +32,7 @@ afterEach(cleanup) const EMPTY: never[] = [] const loadEarlier = () => 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/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 c64cf278377f..54b67ea6f357 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 @@ -196,6 +196,7 @@ describe('outbox queue delivery selection', () => { const view = renderHook( (props: { queuedMessageIds: string[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -279,6 +280,7 @@ describe('outbox queue delivery selection', () => { })) const view = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -310,6 +312,7 @@ describe('outbox queue delivery selection', () => { ]) const view = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, From 5a3afd3d01265b286ee50a8d3973f8888077d2b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:42:14 -0700 Subject: [PATCH 17/22] test(native-chat): import the journal types once in the queue-delivery test --- ...structured-agent-session-outbox.queue-delivery.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 54b67ea6f357..69f802b060de 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 @@ -31,9 +31,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' - -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] From a8cdc0d442d5746c71f27397e80b3c9238b64210 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:24:13 -0700 Subject: [PATCH 18/22] fix(mobile): a resend the host kept as a card shows no error and returns no text The host answers a resend of a message it kept as a card with that message's rejected submission, marked keptAsQueuedMessageId. The phone read it as any rejection: "Message not sent" and the text back in the composer, while the card showed the same text. It now answers like a queued send, as the desktop's send disposition does: the id is spent, no error, and the card holds the text. --- .../mobile-structured-send-delivery.test.ts | 28 +++++++++++++++++++ .../mobile-structured-send-delivery.ts | 6 ++++ 2 files changed, 34 insertions(+) diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 8431e32a7aa9..278142b271cc 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 f20dc0971887..bc0db996a93d 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', From a04591e0d7a6dd35a082725bbbba3276b294d293 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:14:11 -0700 Subject: [PATCH 19/22] test(native-chat): pin that quit's first step stops the queue's hand-off Quit now stops delivery, the queue drain included, at its first step (stopDelivery), before teardown drains recovery. The drain-step quit test runs from that step as well as from the flush. --- ...red-agent-session-unsent-send-hold.test.ts | 92 +++++++++++-------- 1 file changed, 54 insertions(+), 38 deletions(-) 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 index 030c00f8c677..9cf04ddb5be9 100644 --- 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 @@ -344,46 +344,62 @@ describe('the queue at a quit', () => { await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) } - it('a drain step already running when quit begins makes no hand-off', async () => { - 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 - const inDrainStep = (): boolean => - (new Error('which caller').stack ?? '').includes('QueuedMessageDrain.step') - // Holds the drain step at its one await, past its first dispose check, until quit has begun. - const owed = vi - .spyOn(queuedMessages, 'settlementOwed') - .mockImplementation(() => !blocked && inDrainStep()) - const healing = vi.spyOn(queuedMessages, 'settleOwed').mockImplementation(async () => { - if (!blocked && inDrainStep()) { - blocked = true - reached() - await held + // 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 + const inDrainStep = (): boolean => + (new Error('which caller').stack ?? '').includes('QueuedMessageDrain.step') + // Holds the drain step at its one await, past its first dispose check, until quit has begun. + const owed = vi + .spyOn(queuedMessages, 'settlementOwed') + .mockImplementation(() => !blocked && inDrainStep()) + const healing = vi.spyOn(queuedMessages, 'settleOwed').mockImplementation(async () => { + if (!blocked && inDrainStep()) { + 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 } - return settleOwed() - }) - rig.finishCompact() - await inStep - const quitting = host.flushAllStreamedEvents() - release() - await quitting - owed.mockRestore() - healing.mockRestore() - rig.crashRestartHostProcess() + 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() - }) + 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 () => { From 5e1524911f3f500996c54d63a5a0029c25c55c4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:32:31 -0700 Subject: [PATCH 20/22] test(native-chat): give cards their source and store unknown sources as another build would #25078 made a card's source required, so the tests that insert a card pass the person's. The hold's unknown-kind and unreadable-source cases now rewrite the stored row the way a newer build would leave it, instead of casting a type. --- .../journal-unsent-send-hold.test.ts | 31 ++++++++++++++++--- .../queued-message-pause.test.ts | 1 + .../kept-card-downgrade.unit.test.ts | 3 +- 3 files changed, 29 insertions(+), 6 deletions(-) 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 index ef8e31a8aedc..f559f07db12a 100644 --- 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 @@ -29,7 +29,10 @@ import { import type { AgentSessionJournal } from './journal-store' import { closeTestJournalHostDatabases, - createTrackedJournalOpener + createTrackedJournalOpener, + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -116,6 +119,23 @@ async function afterRestart( 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() @@ -274,9 +294,9 @@ describe('which sends an earlier host process left unsent are kept', () => { fence: 0, handoverRecorded: true, origin: 'client', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer build's kind, which this build's type cannot name. - source: { kind: 'a-newer-kind' } as AgentSessionMessageSource + source: USER_MESSAGE_SOURCE }) + storeSubmissionSourceAs(earlier, 'future', { kind: 'a-newer-kind' }) }) expect(journal.submission('future')?.source).toEqual({ kind: 'a-newer-kind' }) await hold(journal) @@ -294,9 +314,9 @@ describe('which sends an earlier host process left unsent are kept', () => { fence: 0, handoverRecorded: true, origin: 'client', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stored source with no kind, which this build's type cannot name. - source: {} as AgentSessionMessageSource + source: USER_MESSAGE_SOURCE }) + storeSubmissionSourceAs(earlier, 'unreadable', {}) }) expect(journal.submission('unreadable')?.source).toEqual({ kind: '' }) await hold(journal) @@ -370,6 +390,7 @@ describe('where kept sends go in the queue', () => { 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', 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 b91f253d0c1a..f9bf0fb2d0bb 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 @@ -528,6 +528,7 @@ describe("a restart's pause", () => { 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) 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 index 24b495575fbd..86eed2b8271e 100644 --- a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -139,7 +139,8 @@ test('an older build lists a kept card first and never sends it, even after a pe messageId: 'queued-card', body: message('queued-card'), fingerprint: 'fp-queued-card', - hostInstance: 'host-a' + hostInstance: 'host-a', + source: USER_MESSAGE_SOURCE }) await acceptPersonSend(earlier, 'kept') await journals.closeAll() From 1c8a2d0c648852396b42d22e27046c1b98f8ecfd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:35:26 -0700 Subject: [PATCH 21/22] refactor(native-chat): stop delivery and the queue drain in one line at quit Main's #25159 left the host at its line limit; quit's stop now disposes both in one expression instead of a block. --- .../agent-session-wire/structured-agent-session-host.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 b9371b7feb60..914203c730cd 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 @@ -266,10 +266,8 @@ export class StructuredAgentSessionHost { // 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, and the * queue hands no card over. */ - stopDelivery = (): void => { - this.conversationDelivery.dispose() - this.queued.drain.dispose() - } + stopDelivery = (): void => + [this.conversationDelivery, this.queued.drain].forEach((d) => d.dispose()) async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.stopDelivery() From b04a1014a120b629a7c0a7daf3b8297f677a19a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:57:07 -0700 Subject: [PATCH 22/22] test(native-chat): hold the drain step without reading the call stack Bun formats a method's stack frame without its class ("at step"), so the quit test's caller check never matched, the step was never held, and both cases timed out once CI ran Vitest on Bun (#25840). Only the drain step heals owed queue bookkeeping, so the hold needs no caller check. --- .../structured-agent-session-unsent-send-hold.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 index 8db627827cc2..0219207a6ccb 100644 --- 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 @@ -370,14 +370,12 @@ describe('the queue at a quit', () => { let reached = (): void => undefined const inStep = new Promise((resolve) => (reached = resolve)) let blocked = false - const inDrainStep = (): boolean => - (new Error('which caller').stack ?? '').includes('QueuedMessageDrain.step') // Holds the drain step at its one await, past its first dispose check, until quit has begun. - const owed = vi - .spyOn(queuedMessages, 'settlementOwed') - .mockImplementation(() => !blocked && inDrainStep()) + // 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 && inDrainStep()) { + if (!blocked) { blocked = true reached() await held