diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index cdc5c21e3c7f..72b78e9e5ab4 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -14,6 +14,8 @@ "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", + "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", + "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/preload/**/*", "../src/shared/**/*", "../src/relay/**/*", diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 5af9040f664b..3ea59e635e22 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -282,7 +282,6 @@ describe('MobileNativeChatQueuedMessages', () => { it('heads the box with why the queue is paused, for each reason', async () => { const rows: readonly [AgentSessionQueuePause['reason'], string][] = [ ['stopped', 'Queue paused because you interrupted'], - ['restarted', 'Queue paused because Orca restarted'], ['cleared', 'Queue paused after you cleared the conversation'] ] for (const [reason, label] of rows) { @@ -318,7 +317,7 @@ describe('MobileNativeChatQueuedMessages', () => { it('Resume asks the host to lift the pause once, however fast it is tapped twice', async () => { let answer: (resumed: boolean) => void = () => undefined const onResume = vi.fn(() => new Promise((resolve) => (answer = resolve))) - const mounted = await mountPaused({ pause: { reason: 'restarted' }, onResume }) + const mounted = await mountPaused({ pause: { reason: 'stopped' }, onResume }) // Both taps land in one frame, before the disabled state can render. await act(async () => { resumeButton(mounted).props.onPress() 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 748dc785db9f..3341b7a80d9c 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -129,9 +129,6 @@ describe('mobileQueuedMessageCards', () => { expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( 'Queue paused because you interrupted' ) - expect(mobileQueuePauseLabel({ reason: 'restarted' })).toBe( - 'Queue paused because Orca restarted' - ) expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( 'Queue paused after you cleared the conversation' ) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index a254e2b65290..6388efbf4123 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -68,7 +68,6 @@ function pausedCaption(reason: string | undefined): string { const QUEUE_PAUSE_LABELS: Readonly> = { stopped: 'Queue paused because you interrupted', - restarted: 'Queue paused because Orca restarted', cleared: 'Queue paused after you cleared the conversation' } 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 0af6a74fffbd..c14e823ab401 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 @@ -581,7 +581,8 @@ describe('mobile structured queued messages', () => { queuedDraft({ messageId: 'behind', position: 2 }) ], [], - { reason: 'restarted' } + // This host publishes no restart pause; a Stop's stands in for any queue-wide one. + { reason: 'stopped' } ) ) ) @@ -590,7 +591,7 @@ describe('mobile structured queued messages', () => { { messageId: 'behind', caption: null } ]) // The kept card is held on its own, so Resume would send the card behind it. - expect(hook!.queued.pause).toEqual({ reason: 'restarted' }) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) }) }) 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 519a62fb3e18..305b7383ad9e 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 @@ -10,7 +10,7 @@ import type { AgentJournalAnsweredTurn } from '../../../shared/agent-session-jou import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' import { - notePersonTurnAccepted, + noteTurnAccepted, placeHandedOverMessage, placeRejectedMessage } from './journal-submission-fold' @@ -60,7 +60,7 @@ export function applyJournalDispatchRow( delete submission.recovered } if (row.state === 'accepted') { - notePersonTurnAccepted(state, submission) + noteTurnAccepted(state, submission) } if (row.state !== 'accepted' || !row.providerItemId) { return 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 fef63417dd6f..72f2b2fec0a4 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 @@ -164,7 +164,7 @@ export class JournalQueuedMessages { /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ userStopInForce(): JournalQueuePauseMarks['latestStop'] { const state = this.deps.state() - return journalUserStopInForce(state.queuePauseMarks, state.latestPersonTurnSequence) + return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) } private derivePauses( @@ -175,16 +175,16 @@ export class JournalQueuedMessages { return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, - latestPersonTurnSequence: state.latestPersonTurnSequence, + latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, cards, hostInstance, restartEnded: this.restartEnded() }) } - /** A person's turn started since this handle opened, which ends a restart's pause. */ + /** A turn started since this handle opened, which ends a restart's pause. */ restartEnded(): boolean { - const latest = this.deps.state().latestPersonTurnSequence + const latest = this.deps.state().latestAcceptedTurnSequence return latest > 0 && !this.deps.wroteBeforeOpen(latest) } diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 27398f9be803..264933b7fb33 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -60,9 +60,9 @@ export type JournalReducerState = { appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope - /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the - * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ - latestPersonTurnSequence: number + /** The submission row of the latest turn the provider accepted, whoever sent it; 0 when none. + * Kept as it folds so the queue's pause reads it in O(1). */ + latestAcceptedTurnSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks } @@ -83,7 +83,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou aliases: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), - latestPersonTurnSequence: 0, + latestAcceptedTurnSequence: 0, queuePauseMarks: createJournalQueuePauseMarks() } } 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 b4dbe3735ba7..395ad50a6ee4 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 @@ -75,7 +75,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, - host.state().latestPersonTurnSequence + host.state().latestAcceptedTurnSequence ), cursor: host.cursor, adopt: host.adopt 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 b93ad8c6fcf7..7b03b4f457f8 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 @@ -112,7 +112,7 @@ export function acceptSubmissionFromProviderItem( } submission.fence = row.fence submission.dispatchState = 'accepted' - notePersonTurnAccepted(state, submission) + noteTurnAccepted(state, submission) submission.providerItemId = providerItemId submission.reason = null submission.resolvedAt = row.ts @@ -125,14 +125,14 @@ export function acceptSubmissionFromProviderItem( }) } -/** A person's turn the provider accepted: the fact the queue's pause is lifted by. */ -export function notePersonTurnAccepted( +/** A turn the provider accepted, whoever sent it: the fact the queue's pause is lifted by. */ +export function noteTurnAccepted( state: JournalReducerState, - submission: Pick + submission: Pick ): void { - if (submission.origin === 'client' && submission.acceptedSequence !== undefined) { - state.latestPersonTurnSequence = Math.max( - state.latestPersonTurnSequence, + if (submission.acceptedSequence !== undefined) { + state.latestAcceptedTurnSequence = Math.max( + state.latestAcceptedTurnSequence, submission.acceptedSequence ) } 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 3553c0c9ff71..83570726945d 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 @@ -1,5 +1,5 @@ -// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, a person's -// accepted turn is a row, and a /clear's carried card names its source. Nothing is stored beside +// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, an accepted +// turn is a row, and a /clear's carried card names its source. Nothing is stored beside // them, so nothing has to retire. import { mkdtemp, rm } from 'node:fs/promises' @@ -68,16 +68,10 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom }) } -/** A turn sent, then accepted by the provider; `origin` says who asked for it. */ -async function turn( - journal: AgentSessionJournal, - id: string, - origin: 'client' | 'host', - accept = true -): Promise { +/** A turn sent, then accepted by the provider. */ +async function turn(journal: AgentSessionJournal, id: string, accept = true): Promise { await journal.appendSubmission({ clientMessageId: id, - origin, payloadFingerprint: `fp-${id}`, body: message(id), fence: 0, @@ -181,24 +175,24 @@ describe("the queue's pause, derived from the journal", () => { expect(pauseTables()).toBe(0) }) - it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { + it("any turn sent after the Stop and accepted lifts it, Orca's own mail included", async () => { const journal = await open() - await turn(journal, 'before-stop', 'client', false) + await turn(journal, 'before-stop', false) await userStop(journal) // Sent before the Stop: its acceptance now does not end a Stop that came after it. await acceptTurn(journal, 'before-stop') - await turn(journal, 'mail', 'host') expect(reason(journal)).toBe('stopped') - await turn(journal, 'typed', 'client', false) + // Orchestration mail: sent, not yet accepted, lifts nothing; accepted, it lifts the pause. + await turn(journal, 'mail', false) expect(reason(journal)).toBe('stopped') - await acceptTurn(journal, 'typed') + await acceptTurn(journal, 'mail') expect(reason(journal)).toBeNull() }) it('a later Stop is the latest, and a Resume row lifts it', async () => { const journal = await open() await userStop(journal) - await turn(journal, 'typed', 'client') + await turn(journal, 'typed') expect(reason(journal)).toBeNull() await userStop(journal) expect(reason(journal)).toBe('stopped') @@ -221,18 +215,18 @@ describe("the queue's pause, derived from the journal", () => { expect(pauseTables()).toBe(0) }) - it("a person's accepted turn on the replacement lifts 'cleared'; a host turn does not", async () => { + it("any accepted turn on the replacement lifts 'cleared', a launch prompt Orca sent included", async () => { const journal = await open() await queueDraft(journal, 'carried', 'source-session') - await turn(journal, 'launch', 'host') + await turn(journal, 'launch', false) expect(reason(journal)).toBe('cleared') - await turn(journal, 'typed', 'client') + await acceptTurn(journal, 'launch') expect(reason(journal)).toBeNull() }) it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => { const journal = await open() - await turn(journal, 'typed', 'client') + await turn(journal, 'typed') await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 0) const db = new Database(journalDatabasePath(root), { readonly: true }) const stored = liveTestJournalRows(db, IDENTITY.sessionId) @@ -304,7 +298,6 @@ describe("the queue's pause, derived from the journal", () => { journal.appendSubmission( { clientMessageId: id, - origin: automatic ? 'host' : 'client', payloadFingerprint: 'fp-draft-1', body: message('draft-1'), fence: 0, @@ -325,12 +318,12 @@ describe("the queue's pause, derived from the journal", () => { expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') }) - it("a rewind's epoch replacement restates a Stop still pausing, and not one a person ended", async () => { + it("a rewind's epoch replacement restates a Stop still pausing, and not one a turn ended", async () => { const journal = await open() await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBe('stopped') - await turn(journal, 'typed', 'client') + await turn(journal, 'typed') await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBeNull() }) @@ -380,7 +373,7 @@ describe("the queue's pause, derived from the journal", () => { }) it.each([ - ["a person's turn", (journal: AgentSessionJournal) => turn(journal, 'typed', 'client')], + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'typed')], ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] ])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => { const journal = await open() @@ -394,7 +387,7 @@ describe("the queue's pause, derived from the journal", () => { it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => { const journal = await open() await queueDraft(journal, 'carried', 'source-session') - await turn(journal, 'typed', 'client') + await turn(journal, 'typed') await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) @@ -427,7 +420,6 @@ describe('which cards a pause holds', () => { await journal.appendSubmission( { clientMessageId: 'send-now-1', - origin: 'client', payloadFingerprint: 'fp-steered', body: message('steered'), fence: 0, @@ -458,7 +450,6 @@ describe('which cards a pause holds', () => { journal.appendSubmission( { clientMessageId: 'drain-newer', - origin: 'host', payloadFingerprint: 'fp-newer', body: message('newer'), fence: 0, @@ -555,7 +546,7 @@ describe('which cards the pauses in force hold', () => { return { messageId, ...base, queuedAt, ...fields } } - /** A Stop at sequence 5 unless `stopped` is 0; no person's turn or Resume since. */ + /** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */ function pausesOver(cards: readonly Card[], stopped = 5) { return deriveQueuePauses({ epoch: 'epoch-1', @@ -563,7 +554,7 @@ describe('which cards the pauses in force hold', () => { latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, resumedSequence: 0 }, - latestPersonTurnSequence: 0, + latestAcceptedTurnSequence: 0, cards, hostInstance: HOST, restartEnded: false 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 5dd79502d69e..5b13856b8f0a 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 @@ -1,17 +1,21 @@ // The queue's pauses: a pure function of the journal fold and the cards, never a stored flag, so // nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: // - 'stopped': the latest Stop event is a person's (reason `user-stop`), with no later Resume row -// and no turn a person asked for sent after it and accepted. A later Stop of any reason -// 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 with no hold of its own was written by another host process, and -// no person's turn has started since this conversation opened. +// and no turn sent after it and accepted. A later Stop of any reason supersedes it; only a +// person's pauses. +// - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has +// happened here since. +// - 'restarted': a waiting card with no hold of its own was written by another host process, +// and no turn has started since this conversation opened. Never published: after a restart +// nothing sends by itself, and the next turn (the carry-on or the person's own message) runs +// first. +// Any accepted turn lifts them, whoever sent it: a person, Orca's own messages, or the queue. // 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 type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -78,28 +82,28 @@ export function foldJournalQueuePauseMark( } } -/** A person's Stop still pauses: it is the latest Stop, and nothing a person did since, and no +/** A person's Stop still pauses: it is the latest Stop, and no turn accepted since, and no * Resume, ended it. A later Stop for any other reason ends it without pausing itself. */ export function journalQueueStopHolds( marks: JournalQueuePauseMarks, - latestPersonTurnSequence: number + latestAcceptedTurnSequence: number ): boolean { return ( marks.latestStop?.event.reason === 'user-stop' && - marks.latestStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + marks.latestStop.sequence > Math.max(latestAcceptedTurnSequence, marks.resumedSequence) ) } /** The latest person's Stop while it still pauses, with where it was written; else null. */ export function journalUserStopInForce( marks: JournalQueuePauseMarks, - latestPersonTurnSequence: number + latestAcceptedTurnSequence: number ): JournalQueuePauseMarks['latestStop'] { - return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.latestStop : null + return journalQueueStopHolds(marks, latestAcceptedTurnSequence) ? marks.latestStop : null } /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a - * person's turn or a Resume happened), then the Stop still in force, in that order so the lift + * turn or a Resume happened), then the Stop still in force, in that order so the lift * never ends the Stop. */ export type JournalQueuePauseRestatement = { lifted: boolean @@ -108,11 +112,11 @@ export type JournalQueuePauseRestatement = { export function journalQueuePauseRestatement( marks: JournalQueuePauseMarks, - latestPersonTurnSequence: number + latestAcceptedTurnSequence: number ): JournalQueuePauseRestatement { return { - lifted: latestPersonTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalUserStopInForce(marks, latestPersonTurnSequence)?.event ?? null + lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0, + liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null } } @@ -121,21 +125,21 @@ export function deriveQueuePauses(input: { /** The journal's epoch: sequences compare only within one. */ epoch: string marks: JournalQueuePauseMarks - latestPersonTurnSequence: number + latestAcceptedTurnSequence: number cards: readonly QueueCard[] hostInstance: string - /** A person's turn started since this conversation opened. */ + /** A turn started since this conversation opened. */ restartEnded: boolean }): DerivedQueuePause[] { - const { epoch, marks, latestPersonTurnSequence } = input + const { epoch, marks, latestAcceptedTurnSequence } = input const pauses: DerivedQueuePause[] = [] - const stop = journalUserStopInForce(marks, latestPersonTurnSequence) + const stop = journalUserStopInForce(marks, latestAcceptedTurnSequence) if (stop) { pauses.push({ reason: 'stopped', since: { epoch, sequence: stop.sequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) - if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { + if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) { pauses.push({ reason: 'cleared', since: null }) } // A card held on its own waits for its own Send whoever wrote it, so it pauses nothing else. @@ -165,11 +169,6 @@ 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 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 * a Stop's that holds nothing never hides a restart's. The drain, its consume, and publication * all read it. */ @@ -180,7 +179,8 @@ export function queuePauseHolding( if (card.state !== 'waiting' || card.holdReason !== null) { return undefined } - return pauses.find((pause) => PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) + // A card queued AFTER a Stop is a new instruction and is not held; it still waits behind a held one. + return pauses.find((pause) => queuedBeforePause(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a @@ -220,3 +220,17 @@ export function resumableQueuePause( } return null } + +/** A turn sent after `pause` began that the agent has not answered yet: its acceptance lifts the + * pause, whoever sent it, so the pause is not shown meanwhile; a refusal shows it again. A send + * made before a Stop lifts nothing, so it never counts. */ +export function queuePauseLiftOnItsWay( + pause: DerivedQueuePause, + submissions: readonly Pick[] +): boolean { + const anchor = pause.since?.sequence ?? 0 + return submissions.some( + (submission) => + submission.dispatchState === 'pending' && (submission.acceptedSequence ?? 0) > anchor + ) +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts index 7eeadd71359f..07e197ceb6a8 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts @@ -66,7 +66,11 @@ export function buildSubscriberFrame( ...event, ...(includeCommands ? { commands: commands ?? null } : {}), ...(attachedQueued && queued - ? { queuedMessages: queued.queuedMessages, queuePause: queued.queuePause } + ? { + queuedMessages: queued.queuedMessages, + queuePause: queued.queuePause, + nextQueuedMessageId: queued.nextQueuedMessageId + } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index d6f44c7bafae..cf44fc3d66b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -7,7 +7,10 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' -import { tryReadQueuePublication } from './structured-agent-session-queued-publication' +import { + structuredQueueSendGate, + tryReadQueuePublication +} from './structured-agent-session-queued-publication' import type { AgentSessionHistoryScope } from './agent-session-history-page' import type { AgentSessionSubscribers, @@ -49,7 +52,10 @@ export class StructuredAgentSessionBackgroundTaskChannel { scope }) const backgroundTasks = this.read(request.sessionId) - const queue = tryReadQueuePublication(journal) + const queue = tryReadQueuePublication( + journal, + structuredQueueSendGate(this.deps.store, request.sessionId) + ) const hostNow = this.deps.now?.() ?? Date.now() return { ...result, @@ -59,7 +65,11 @@ export class StructuredAgentSessionBackgroundTaskChannel { // A stale history answer never replaces newer live subscription state; // the client's reducer keeps live-over-history precedence. ...(queue !== undefined - ? { queuedMessages: queue.queuedMessages, queuePause: queue.queuePause } + ? { + queuedMessages: queue.queuedMessages, + queuePause: queue.queuePause, + nextQueuedMessageId: queue.nextQueuedMessageId + } : {}), backgroundTasks } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 744eba82c505..6d4952670a45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -329,8 +329,7 @@ it('a card sent now into the running turn comes back paused when Stop withdraws const queuedSend = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery, - userSend: true + delivery }) if (!queuedSend.ok || !('queued' in queuedSend.value)) { throw new Error('expected a queued receipt') @@ -347,7 +346,7 @@ it('a card sent now into the running turn comes back paused when Stop withdraws const sends = async () => (await host.journalSnapshot(SESSION)).submissions .filter((entry) => entry.queuedMessageId === cardId) - .map((entry) => ({ origin: entry.origin, state: entry.dispatchState, reason: entry.reason })) + .map((entry) => ({ state: entry.dispatchState, reason: entry.reason })) await eventually(async () => expect((await sends())[0]?.state).toBe('pending')) expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -368,14 +367,12 @@ it('a card sent now into the running turn comes back paused when Stop withdraws }).toEqual({ pause: { reason: 'stopped' }, cards: ['waiting'], - sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + sends: [{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] }) }, 5_000) // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. await eventually(async () => expect(await liveTurnId()).toBeNull()) await new Promise((resolve) => setTimeout(resolve, 2_500)) expect(connection.sent).toHaveLength(2) - expect(await sends()).toEqual([ - { origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED } - ]) + expect(await sends()).toEqual([{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }]) }, 20_000) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 449b2645d601..ed06f809c6d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -586,8 +586,7 @@ it('sends a queue-if-active message issued while the Stop ends the child directl const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery: 'queue-if-active' }, fence), body, - delivery: 'queue-if-active', - userSend: true + delivery: 'queue-if-active' }) frame(connection, INTERRUPTED_RESULT) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 159497b64e80..9c0c5b58b7bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -5,7 +5,10 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { SubscriberFieldHooks } from './agent-session-subscriber-frame-fields' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' -import { tryReadQueuePublication } from './structured-agent-session-queued-publication' +import { + structuredQueueSendGate, + tryReadQueuePublication +} from './structured-agent-session-queued-publication' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -61,7 +64,10 @@ export class StructuredAgentSessionClientDelivery { this.subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => this.readCommands(sessionId), readQueuePublication: (sessionId) => - tryReadQueuePublication(sessions.get(sessionId)?.journal), + tryReadQueuePublication( + sessions.get(sessionId)?.journal, + structuredQueueSendGate(this.deps().store, sessionId) + ), readBackgroundTasks, onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) }) 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 9eb3126c197c..8055af641583 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 @@ -56,8 +56,8 @@ export function sendStructuredAgentSessionTurn( delivery?: 'queue-if-active' /** Host-local, set only by the client-facing `agentSession.send` RPC (the * renderer's launch prompt included): recorded as the submission's `client` - * origin, whose started turn ends a Stop's or a restart's queue pause. - * Orchestration mail, a restart continuation and `agent.launch`'s host-sent + * origin, so a restart or a close keeps it as a card if it never reached the + * agent. Orchestration mail, a restart continuation and `agent.launch`'s host-sent * prompt never set it. */ userSend?: true /** Host-local, never on the wire: a person's message the host sends for them, such as a diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts index ea2a0cd14617..9b03b07ed501 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts @@ -143,8 +143,7 @@ it("takes a send past a newer Orca's live approval, and the next turn's card can expect( await host.send(CALLER, { envelope: envelope('agentSession.send', queuedFields), - ...queuedFields, - userSend: true + ...queuedFields }) ).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) expect(hostTestState().dispatch).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts new file mode 100644 index 000000000000..b169bbe4e5e7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -0,0 +1,591 @@ +// The queue sends its cards strictly in order. The host publishes the queue's pause and the card it +// sends next. A client reading its updates one at a time, as the chat does, reads one working state +// across a turn's end or a Resume and the queue's send of the next card: the working status and +// row, the pickers, the composer button and the card labels never flip in between. After a restart +// nothing sends by itself and no pause shows: the chat's next turn runs first, then the cards. +// Where the host would refuse the send, it names no next card, so the chat reads idle. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../shared/agent-session-wire' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { + projectQueuedMessageCards, + queuedMessageCardSteers, + queuedMessagesQueuePause +} from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' +import { + nativeChatComposerPrimaryAction, + type NativeChatComposerPrimaryAction +} from '../../../renderer/src/components/native-chat/native-chat-composer-primary-action' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { + readQueuePublication, + structuredQueueSendGate +} from './structured-agent-session-queued-publication' +import { + structuredAgentSessionHostInstance, + structuredQueuePauses +} from './structured-agent-session-queued-pause' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { + HOST_TEST_SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** What a reader opening the chat now is told: the queue's pause and the card it sends next. */ +async function published(): Promise<{ + queuePause: AgentSessionQueuePause | null + nextQueuedMessageId: string | null +}> { + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return { + queuePause: page.page.queuePause ?? null, + nextQueuedMessageId: page.page.nextQueuedMessageId ?? null + } +} + +/** The pauses in force, published or not: a restart's is never published. */ +function derivedPauses(): string[] { + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return structuredQueuePauses(journal).map((pause) => pause.reason) +} + +/** Lets the drain run its steps; none may hand a card off. */ +async function expectNothingSent(...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } +} + +type ClientView = { + /** The chat reads as working: transcript status, working row and timer, the pickers. */ + working: boolean + /** A Stop pressed now would stop something. */ + stopLive: boolean + /** The empty composer's primary button. */ + button: NativeChatComposerPrimaryAction + /** The header row above the cards: the queue is held. */ + header: boolean + /** A new message first asks whether to clear the cards ("Send message?"). */ + dialog: boolean + /** Each card's Send-now reads Steer. */ + steers: boolean[] + /** Why each card waits, which picks its caption: 'turn' is a plain waiting card. */ + holds: string[] + cards: number + nextQueuedMessageId: string | null +} + +/** A composer message: sent with the composer's queue delivery. */ +function composerSend(text: string): ReturnType { + return rig.send(text, 'queue-if-active') +} + +/** Folds every published update as the chat does, one at a time, into what it shows. */ +async function watchClient(): Promise { + const views: ClientView[] = [] + const submissions = new Map() + const turns = new Map() + let queued: AgentSessionQueuedMessage[] = [] + let queuePause: AgentSessionQueuePause | null = null + let nextQueuedMessageId: string | null = null + await rig.host.subscribe({ + id: 'client-view', + sessionId: HOST_TEST_SESSION, + emit: (event) => { + if (event.type === 'end') { + return + } + const rows = event.type === 'batch' ? event.batch : event.page + for (const submission of rows.submissions) { + submissions.set(submission.clientMessageId, submission) + } + for (const item of rows.items) { + if (item.body.kind === 'turn') { + turns.set(item.itemId, item.body.state) + } + } + if (event.queuedMessages !== undefined) { + queued = event.queuedMessages ?? [] + queuePause = event.queuePause ?? null + nextQueuedMessageId = event.nextQueuedMessageId ?? null + } + const running = [...turns].find(([, state]) => state === 'running')?.[0] ?? null + const all = [...submissions.values()] + const hostWorking = isStructuredAgentSessionMainAgentWorking(running, all) + // As use-structured-agent-session.ts derives it. + const working = hostWorking || (nextQueuedMessageId !== null && !hostWorking) + const cards = projectQueuedMessageCards(queued, all, { + hasPendingPrompt: false, + queuePaused: queuePause !== null + }) + // As use-structured-agent-session-queued-messages.ts derives them. + const header = queuedMessagesQueuePause(cards, queuePause) !== null + const queueHeld = header && !working + views.push({ + working, + stopLive: hostWorking, + button: nativeChatComposerPrimaryAction({ + isWorking: working, + composerEmpty: true, + queueHeld + }), + header, + dialog: queueHeld, + steers: cards.map((card) => queuedMessageCardSteers(card)), + holds: cards.map((card) => card.hold), + cards: cards.length, + nextQueuedMessageId + }) + } + }) + return views +} + +/** Every update in the run reads working, with Stop and Steer, and the gap update is among them. */ +function expectOneWorkingRun(views: readonly ClientView[]): void { + expect(views.filter((view) => !view.working || view.button !== 'stop')).toEqual([]) + expect(views.flatMap((view) => view.steers)).not.toContain(false) + // Between a turn's end (or a Resume) and the queue's send: nothing in flight yet, still working. + expect(views.some((view) => !view.stopLive && view.nextQueuedMessageId !== null)).toBe(true) +} + +describe('a Stop holds the queue, in order', () => { + it('a card typed while the Stop lands waits behind the held one; Resume sends both in order, with no Resume or Send between them', async () => { + const working = await rig.workingSend() + const held = await queuedDraft('held by the stop') + await rig.stop() + const typed = await queuedDraft('typed while the stop lands') + const views = await watchClient() + // Still winding down: the paused row shows, but neither Resume nor "Send message?" while it runs. + expect(views.at(-1)).toMatchObject({ working: true, header: true, dialog: false }) + await rig.settleAccepted(working, 'stopped') + await eventually(() => expect(views.at(-1)).toMatchObject({ header: true, dialog: true })) + expect(views.at(-1)?.button).toBe('resume') + await expectNothingSent(held, typed) + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + const resumedAt = views.length + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + expect(await rig.handoff(typed)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(held), 'held') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + expectOneWorkingRun(views.slice(resumedAt)) + }) + + it('after Resume over held cards the chat goes from idle with Resume straight to working with Stop', async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + await queuedDraft('second') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const views = await watchClient() + const before = views.length + expect(views.at(-1)).toMatchObject({ working: false, button: 'resume' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + await eventually(() => expect(views.at(-1)?.stopLive).toBe(true)) + expectOneWorkingRun(views.slice(before)) + }) +}) + +describe('after a restart, nothing sends by itself', () => { + /** A turn runs with cards A and B waiting, and Orca quits: a quit writes no Stop event. */ + async function restartedWithCards(): Promise<{ first: string; second: string }> { + await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + rig.crashRestartHostProcess() + // The new host opens the chat for its first reader. + await published() + return { first, second } + } + + /** The same, through a restart whose old agent this rig can still settle, so the next turn can + * reach the agent: the turn that ran ends before anything new is sent. */ + async function restartedIdleWithCards(): Promise<{ first: string; second: string }> { + const working = await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'before-restart') + await published() + return { first, second } + } + + it('idle: no card is handed off across opens and commits, and no pause or next card is published', async () => { + const { first, second } = await restartedWithCards() + const views = await watchClient() + await expectNothingSent(first, second) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + expect(derivedPauses()).toEqual(['restarted']) + // Closed and opened again (the idle sweep, a reconnect): the same. + await rig.host.close(HOST_TEST_SESSION, 'evict') + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + await expectNothingSent(first, second) + // The chat reads idle with plain cards: no paused row, no Resume, no "Send message?". + expect(views.at(-1)).toMatchObject({ + working: false, + header: false, + dialog: false, + button: 'send', + cards: 2, + holds: ['turn', 'turn'], + steers: [true, true] + }) + expect(await rig.drafts()).toEqual([ + { messageId: first, state: 'waiting' }, + { messageId: second, state: 'waiting' } + ]) + }) + + it("Resume: Orca's carry-on turn runs first, then A, then B, as if no restart happened", async () => { + const { first, second } = await restartedIdleWithCards() + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + const views = await watchClient() + // The restart prompt's Resume sends the carry-on as Orca's own message, straight to the agent. + const carryOn = rig.send('continue where you left off') + expect(await carryOn.result).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + await eventually(async () => + expect((await rig.submission(carryOn.id))?.handedOverAt).toBeDefined() + ) + await expectNothingSent(first, second) + await rig.settleAccepted(carryOn.id, 'carry-on') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'A') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + expect(views.filter((view) => view.header || view.dialog)).toEqual([]) + expectOneWorkingRun(views.slice(views.findIndex((view) => view.working))) + }) + + it("the person's own message goes straight to the agent first, then A, then B", async () => { + const { first, second } = await restartedIdleWithCards() + const views = await watchClient() + // Sent with the composer's queue delivery: nothing ahead may send, so it is not queued. + const message = composerSend('a new instruction') + expect(await message.result).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + await expectNothingSent(first, second) + await rig.settleAccepted(message.id, 'message') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'A') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + expect(views.filter((view) => view.header || view.dialog)).toEqual([]) + }) + + it('Steer on a card sends it now, and the rest follow it in order', async () => { + const { first, second } = await restartedIdleWithCards() + expect(await rig.sendNow(second)).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: second } } + }) + await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) + await expectNothingSent(first) + await rig.settleAccepted(await rig.handoffId(second), 'B') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + }) + + it("a Stop's row from before the restart is not shown either; the carry-on releases every card, in order", async () => { + const working = await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + await rig.restartHostProcess() + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + expect(derivedPauses()).toEqual(['stopped', 'restarted']) + const views = await watchClient() + expect(views.at(-1)).toMatchObject({ header: false, dialog: false, button: 'send' }) + const carryOn = rig.send('continue where you left off') + await carryOn.result + await expectNothingSent(first, second) + await rig.settleAccepted(carryOn.id, 'carry-on') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'A') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + expect(views.filter((view) => view.header || view.dialog)).toEqual([]) + }) + + it('a card written after the restart waits while any card from before it waits; then it is an ordinary queue', async () => { + const { first, second } = await restartedIdleWithCards() + // Your message goes straight to the agent; while it waits, the next one queues. + const message = rig.send('a new instruction') + await message.result + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + const typed = await queuedDraft('typed while it waits') + // The agent refuses it: no turn started, so every card waits, the new one included. + await rig.settleRejected(message.id, 'turn/start refused') + await expectNothingSent(first, second, typed) + expect(derivedPauses()).toEqual(['restarted']) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) + await expectNothingSent(second, typed) + // With nothing from before the restart left, the card sends like any queued card. + expect(await rig.deleteQueued(second)).toMatchObject({ ok: true, value: { deleted: true } }) + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + }) + + it('a hand-off the quit cut short goes back to waiting first, and nothing sends', async () => { + rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) + const working = await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + // The agent at rest goes; Resume's hand-off of A must start a new one, which never starts. + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + const cutShort = await rig.handoffId(first) + expect((await rig.handoff(first))?.handedOverAt).toBeUndefined() + rig.crashRestartHostProcess() + rig.awaitStarted.mockImplementation(async () => undefined) + release() + await published() + // The new host refuses the leftover hand-off, and A waits again in its own place. + await eventually(async () => + expect(await rig.drafts()).toEqual([ + { messageId: first, state: 'waiting' }, + { messageId: second, state: 'waiting' } + ]) + ) + await expectNothingSent() + expect(await rig.handoffId(first)).toBe(cutShort) + expect(await rig.handoff(second)).toBeUndefined() + expect(derivedPauses()).toEqual(['restarted']) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + }) + + it('an epoch replacement (a rewind or a legacy import) before any turn still holds the cards', async () => { + // A turn happened in this chat before, so the new epoch restates a lift. + await rig.settleAccepted(await rig.workingSend(), 'earlier') + const { first, second } = await restartedWithCards() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + await journal.replaceEpochItems( + 'legacy_import', + structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION), + [] + ) + await expectNothingSent(first, second) + expect(derivedPauses()).toEqual(['restarted']) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + }) +}) + +describe("a message sent over a held queue (the confirmation's Send message)", () => { + /** Two cards a Stop holds, the stopped turn over: the paused row and Resume show. */ + async function heldIdleQueue() { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const views = await watchClient() + expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', dialog: true, cards: 2 }) + return { first, second, views } + } + + it('goes out at once: the paused row goes as it is sent and never comes back, and the held cards follow it in order', async () => { + const { first, second, views } = await heldIdleQueue() + // Sent with the composer's queue delivery: no card ahead may send, so nothing queues it. + const message = composerSend('a new instruction') + const sentAt = views.length + expect(await message.result).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + // The cards stay held until the agent accepts the message's turn, but the host no longer says + // paused: that turn is on its way to lift it. + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + expect(await rig.handoff(first)).toBeUndefined() + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + await eventually(() => expect(views.at(-1)?.stopLive).toBe(true)) + const acceptedAt = views.length + await rig.settleAccepted(message.id, 'message') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'first') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + // Updates arrived while the message waited for the agent, with both cards still held. + const waiting = views.slice(sentAt, acceptedAt) + expect(waiting.some((view) => view.stopLive && view.cards === 2)).toBe(true) + expect(views.slice(sentAt).filter((view) => view.header)).toEqual([]) + expect(views.flatMap((view) => view.steers)).not.toContain(false) + }) + + it('Steer on a held card: the paused row goes from the press until its turn is accepted, then the rest follow', async () => { + const { first, second, views } = await heldIdleQueue() + const pressedAt = views.length + expect(await rig.sendNow(second)).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) + expect((await published()).queuePause).toBeNull() + await rig.settleAccepted(await rig.handoffId(second), 'second') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(views.slice(pressedAt).filter((view) => view.header || view.dialog)).toEqual([]) + }) + + it('a Steer the agent refuses lifts nothing: the paused row comes back over the card before it', async () => { + const { first, second, views } = await heldIdleQueue() + expect(await rig.sendNow(second)).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) + await eventually(() => expect(views.at(-1)?.header).toBe(false)) + await rig.settleRejected(await rig.handoffId(second), 'turn/start refused') + await eventually(() => expect(views.at(-1)?.header).toBe(true)) + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(first)).toBeUndefined() + }) + + it('a send the agent refuses lifts nothing: the paused row comes back over the held cards', async () => { + const { first, views } = await heldIdleQueue() + const message = composerSend('refused') + await message.result + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + await eventually(() => expect(views.at(-1)?.header).toBe(false)) + await rig.settleRejected(message.id, 'turn/start refused') + await eventually(() => expect(views.at(-1)).toMatchObject({ header: true, cards: 2 })) + expect(await rig.handoff(first)).toBeUndefined() + }) +}) + +describe("the queue's next card on a history page", () => { + it('names the card a Resume released while the drain still waits for the session', async () => { + const working = await rig.workingSend() + const card = await queuedDraft('held, then released') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + // The Resume row is written; its adoption, and so the drain behind it, waits. + let release: () => void = () => undefined + const adopt = vi + .spyOn(JournalQueuedMessages.prototype, 'adopt') + .mockImplementationOnce(() => new Promise((resolve) => (release = () => resolve(false)))) + const resumed = rig.resume() + try { + await eventually(async () => { + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(page.ok && page.page.nextQueuedMessageId).toBe(card) + }) + } finally { + release() + adopt.mockRestore() + } + expect(await resumed).toMatchObject({ ok: true, value: { resumed: true } }) + }) +}) + +describe('where the host would refuse the send', () => { + /** An idle source a /clear replaced, still holding a card: a crash before the carry leaves it. */ + async function cardLeftOnAClearedSource() { + await rig.settleAccepted(await rig.workingSend(), 'a') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + expect(cleared).toMatchObject({ ok: true }) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the source open') + } + const card = 'left-behind' + await journal.queuedMessages.insert({ + messageId: card, + body: hostTestMessage('left on the source'), + fingerprint: 'fp-left-behind', + hostInstance: structuredAgentSessionHostInstance() + }) + const record = rig.store.getRecord(HOST_TEST_SESSION) + if (!record) { + throw new Error('expected the record') + } + return { card, journal, record, fence: record.lease.runtimeFence } + } + + it('a source a /clear replaced names no next card; the same idle state with no block names it', async () => { + const { card, journal, record, fence } = await cardLeftOnAClearedSource() + const gate = structuredQueueSendGate(rig.store, HOST_TEST_SESSION) + expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBeNull() + const { conversationCommand: _cleared, ...unblocked } = record + const next = readQueuePublication(journal, () => ({ record: unblocked, fence })) + expect(next.nextQueuedMessageId).toBe(card) + }) + + it('a rewind whose outcome is unknown names no next card', async () => { + const { journal, record, fence } = await cardLeftOnAClearedSource() + const { conversationCommand: _cleared, ...unblocked } = record + const rewind = { + operationId: hostTestOperationId(), + callerKey: QUEUED_RIG_CALLER.callerKey, + itemId: 'item-1', + expectedEpoch: 'epoch-1', + phase: 'prepared' as const, + retained: [] + } + const next = (gateRecord: typeof record) => + readQueuePublication(journal, () => ({ record: gateRecord, fence })).nextQueuedMessageId + expect(next({ ...unblocked, rewind })).toBeNull() + expect(next({ ...unblocked, rewind: { ...rewind, phase: 'provider-succeeded' } })).toBeNull() + expect(next({ ...unblocked, rewind: { ...rewind, phase: 'completed' } })).not.toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 144a43302b71..dda3d2112cd3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -160,8 +160,7 @@ describe('/clear', () => { expect( await rig.host.send(CALLER, { envelope: rig.envelope(fields, 'agentSession.send', sentId, replacementId), - ...fields, - userSend: true + ...fields }) ).toMatchObject({ ok: true, value: { submission: expect.anything() } }) await eventually(() => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index 26eaa8b8ab59..09ba1797b29a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -241,8 +241,7 @@ describe('the hand-off link on answers', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } await host.send(CALLER, params) await settleAccepted(working, 'a') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 65613b2e621a..7418e7eb8df0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -264,7 +264,7 @@ describe('drain', () => { }) describe('held drafts', () => { - it('a restart pauses the queue, reason restarted, and it survives a reopen; never auto-sent', async () => { + it('a restart holds the queue without ever showing a pause, across a reopen; never auto-sent', async () => { const working = await workingSend() const queued = await send('written before the restart', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -273,11 +273,11 @@ describe('held drafts', () => { const draftId = queued.value.queued.messageId await rig.restartHostProcess() await settleAccepted(working, 'a') - // The queue is paused, not the card: it carries no hold of its own. + // The queue is held, not the card: it carries no hold of its own, and no pause is published. expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() await host.close(SESSION, 'evict') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await sendNow(draftId)).toMatchObject({ @@ -286,7 +286,7 @@ describe('held drafts', () => { }) }) - it("a restart's pause also lifts when the user's next send starts its turn, exactly like a Stop's", async () => { + it("a restart's hold lifts when the user's next send starts its turn, exactly like a Stop's", async () => { const working = await workingSend() const queued = await send('written before the restart', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -297,11 +297,11 @@ describe('held drafts', () => { await settleAccepted(working, 'a') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() // The user's send starting its turn lifts it, and adopts the row into this instance. const next = send('user starts a new turn') await next.result - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.handoff(draftId)).toBeUndefined() await settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) @@ -449,17 +449,18 @@ describe('Stop and Delete', () => { await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - // The host accepting the send is not yet a turn: the pause lifts when the - // provider accepts it, and the draft drains after that turn. + // The host accepting the send is not yet a turn: the pause lifts when the provider accepts + // it, and the draft drains after that turn. Meanwhile no pause is shown: it is on its way. const next = send('user starts a new turn') await next.result - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + expect(await rig.handoff(draftId)).toBeUndefined() await settleAccepted(next.id, 'b') expect(await rig.queuePause()).toBeNull() await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it('a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) never lifts the pause', async () => { + it("a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) lifts the pause once accepted, like a person's", async () => { const working = await workingSend() const queued = await send('paused by stop', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -469,15 +470,15 @@ describe('Stop and Delete', () => { await stop() await settleAccepted(working, 'a') // All three reach the host as a send the client send RPC did not make. - const mail = send('coordinator mail', undefined, { internal: true }) + const mail = send('coordinator mail') expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) - // The journal records who asked, which is what the pause reads. - expect(await rig.submission(mail.id)).toMatchObject({ origin: 'host' }) - expect(await rig.submission(working)).toMatchObject({ origin: 'client' }) - await settleAccepted(mail.id, 'b') await new Promise((resolve) => setTimeout(resolve, 250)) + // Not shown while the mail awaits the agent, and nothing sends yet. + expect(await rig.queuePause()).toBeNull() expect(await rig.handoff(draftId)).toBeUndefined() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await settleAccepted(mail.id, 'b') + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { 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 225467d2dbda..3acc45552aa6 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 @@ -120,6 +120,27 @@ export function structuredQueueHold(input: { return null } +/** The card the drain sends next, or null while anything holds the queue: the drain's own pick + * through the one gate, so a client told this reads what the drain acts on. Live facts only; the + * backlog is never a gate, so a lone draft drains. */ +export function nextStructuredQueuedMessage(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + fence: number +}): QueuedMessageRow | null { + const next = oldestActionableQueuedMessage(input.journal) + const { journal, fence } = input + // The gate's cheap `working` first: publication asks on every streamed frame, and the gate's + // prompt check walks the whole fold. + if ( + next === null || + isStructuredAgentSessionMainAgentWorking(journal.activeTurnId(), journal.submissions(), fence) + ) { + return null + } + return structuredQueueHold(input) === null ? next : null +} + /** * Whether a `queue-if-active` send becomes a draft: any queue hold short of * `blocked`, or an actionable draft already exists (FIFO backlog — an @@ -263,7 +284,7 @@ export class StructuredAgentSessionQueuedMessageDrain { } schedule(sessionId: string): void { - const journal = this.deps.sessions.get(sessionId)?.journal + const journal = this.disposed ? undefined : this.deps.sessions.get(sessionId)?.journal if (!journal) { return } @@ -321,16 +342,11 @@ export class StructuredAgentSessionQueuedMessageDrain { }) }) } - const next = oldestActionableQueuedMessage(journal) - if (!next) { - return - } - const record = this.deps.getRecord(sessionId) const fence = this.deps.conversationFence(sessionId) - // 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 (this.disposed || structuredQueueHold({ journal, record, fence }) !== null) { + // Whatever clears a hold publishes or commits, which re-derives this step. + const record = this.deps.getRecord(sessionId) + const next = nextStructuredQueuedMessage({ journal, record, fence }) + if (this.disposed || !next) { return } // Always a fresh id: the submission names its draft by `queuedMessageId`, never by id equality. @@ -339,7 +355,7 @@ export class StructuredAgentSessionQueuedMessageDrain { await journal.appendSubmission( { clientMessageId: submissionId, - // The queue's own automatic send: it never ends a pause. + // The queue's own automatic send, never kept as a card by a restart or a close. origin: 'host', payloadFingerprint: next.fingerprint, body: next.body, 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 33142a830a50..b637e1fc89e4 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 @@ -224,7 +224,7 @@ export function sendQueuedStructuredAgentMessage( await ctx.journal.appendSubmission( { clientMessageId: submissionId, - // The person asked for this turn, so it ends a Stop's pause once it starts. + // The person asked for this turn: a restart or a close keeps it as a card. origin: 'client', payloadFingerprint: row.fingerprint, body: row.body, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index c5baeed25def..cf1746eaaf0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -1,6 +1,6 @@ -// A Stop pauses the whole queue, derived from the journal: it lasts until a turn -// a person asked for (a send over the client RPC, or a card they sent now) -// starts — the provider accepts it, never merely the host — or they Resume. +// A Stop pauses the whole queue, derived from the journal: it lasts until any turn +// sent after it starts — the provider accepts it, never merely the host — or the +// person Resumes. Whoever sent that turn: a person, Orca's own mail, or the queue. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -41,6 +41,15 @@ async function expectPaused(...draftIds: string[]): Promise { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) } +/** The pauses in force, published or not: a restart's is never published. */ +function derivedPauses(): string[] { + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return structuredQueuePauses(journal).map((pause) => pause.reason) +} + async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -75,7 +84,8 @@ describe("a Stop's queue pause", () => { it('outlives a user send the provider accepts and then refuses; a later send that starts lifts it', async () => { const draftId = await stoppedDraft() const refused = await handedOverUserSend('the start fails') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // On its way to lift the pause, so not shown; the refusal brings it back. + expect(await rig.queuePause()).toBeNull() await rig.settleRejected(refused, 'turn/start refused') await expectPaused(draftId) const started = await handedOverUserSend('this one starts') @@ -97,10 +107,14 @@ describe("a Stop's queue pause", () => { // Derived from the journal, not remembered: a restart forgets nothing it needs. The process // dies with no close, as a quit writes no Stop event to end the pause either. rig.crashRestartHostProcess() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Still derived, though after a restart no pause is shown. + expect(await rig.queuePause()).toBeNull() + expect(derivedPauses()).toEqual(['stopped', 'restarted']) await rig.settleAccepted(inFlight, 'after-restart') - // The Stop's pause is over; the restart's own lasts until a turn asked for since it. - await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) + // The Stop's pause is over; the restart's own, never shown, lasts until a turn sent since it. + await eventually(async () => expect(derivedPauses()).toEqual(['restarted'])) + expect(await rig.queuePause()).toBeNull() + expect(await rig.handoff(draftId)).toBeUndefined() const next = rig.send('sent after the restart') await next.result await rig.settleAccepted(next.id, 'next') @@ -128,13 +142,17 @@ describe("a Stop's queue pause", () => { await rig.settleAccepted(working, 'stopped') expect(await rig.sendNow(sentId)).toMatchObject({ ok: true, - value: { submission: { origin: 'client', queuedMessageId: sentId } } + value: { submission: { queuedMessageId: sentId } } }) await handedOver(sentId) - // Only the card the user asked for went: the queue is still paused. - await expectPaused(heldId) + // Only the card the user asked for went: the queue is still paused, though not shown while + // that turn is on its way. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(heldId)).toBeUndefined() + expect(derivedPauses()).toEqual(['stopped']) + expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toEqual([{ messageId: heldId, state: 'waiting' }]) - // A turn the user asked for has now started, which ends the pause. + // A turn sent after the Stop has now started, which ends the pause. await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) }) @@ -167,8 +185,7 @@ describe("a Stop's queue pause", () => { const body = hostTestMessage('work on this') const replayed = await rig.host.send(QUEUED_RIG_CALLER, { envelope: rig.envelope({ body }, 'agentSession.send', working), - body, - userSend: true + body }) expect(replayed).toMatchObject({ ok: true, @@ -176,15 +193,19 @@ describe("a Stop's queue pause", () => { value: { submission: { dispatchState: 'accepted' } } }) // A later journal commit re-derives the pause; the old send was accepted before the Stop. - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await rig.settleAccepted(mail.id, 'mail') + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-later', ordinal: 901 }, + { kind: 'turn', turnId: 'turn-later', state: 'completed', startedAt: 1 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await expectPaused(draftId) }) }) describe('the pause read', () => { - it("costs no scan of the submissions: the reducer keeps the latest person's accepted turn", async () => { + it('costs no scan of the submissions: the reducer keeps the latest accepted turn', async () => { const draftId = await stoppedDraft() const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal if (!journal) { @@ -195,14 +216,11 @@ describe('the pause read', () => { expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) expect(scan).not.toHaveBeenCalled() scan.mockRestore() - const mail = rig.send('coordinator mail', undefined, { internal: true }) + const mail = rig.send('coordinator mail') await mail.result - await rig.settleAccepted(mail.id, 'mail') - // Orca's own turn lifts nothing; a person's does. expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) - const next = rig.send('user starts a new turn') - await next.result - await rig.settleAccepted(next.id, 'next') + // Orca's own turn, accepted after the Stop, lifts it like a person's. + await rig.settleAccepted(mail.id, 'mail') expect(structuredQueuePauses(journal)).toEqual([]) await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) @@ -227,7 +245,7 @@ describe('a card queued after a Stop is a new instruction', () => { expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') await rig.settleAccepted(working, 'stopped') - const mail = rig.send('coordinator mail', undefined, { internal: true }) + const mail = rig.send('coordinator mail') await mail.result await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() @@ -235,8 +253,8 @@ describe('a card queued after a Stop is a new instruction', () => { const later = await queuedDraft('typed during the mail turn') await rig.settleAccepted(mail.id, 'mail') await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal && structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) + // The mail's turn, sent after the Stop, ended it. + expect(derivedPauses()).toEqual([]) }) it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { @@ -343,13 +361,14 @@ describe('a pause only over cards Resume could send', () => { }) describe("a restart's pause", () => { - it("once a person's turn ends it, stays ended when the conversation reopens", async () => { + it('once a turn ends it, stays ended when the conversation reopens', async () => { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') await rig.restartHostProcess() await rig.settleAccepted(working, 'a') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() + expect(derivedPauses()).toEqual(['restarted']) const next = rig.send('user starts a new turn') await next.result await rig.settleAccepted(next.id, 'b') @@ -431,12 +450,11 @@ describe('Resume', () => { await expectPaused(draftId) }) - it("lifts a restart's pause too", async () => { - const working = await rig.workingSend() - const draftId = await queuedDraft('written before the restart') - await rig.restartHostProcess() - await rig.settleAccepted(working, 'a') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + it("of a Stop from before a restart, which no client offers there, also lifts the restart's pause", async () => { + const draftId = await stoppedDraft() + rig.crashRestartHostProcess() + expect(await rig.queuePause()).toBeNull() + expect(derivedPauses()).toEqual(['stopped', 'restarted']) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) 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..6722313d0394 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 @@ -30,9 +30,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. + * Every journal publish: a restart's rows are adopted into this instance once a 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. */ export async function adoptEndedRestartPause( sessionId: string, 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 50f3b9ef270e..af1b7e567b3f 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 @@ -1,7 +1,7 @@ -// The published view of a conversation's queue: its whole draft list and the -// queue's pause, on the `commands` precedent — read per emit, reference-stable -// while unchanged, so the subscribers' identity dedup keeps token streams from -// re-sending it. The two ride together: a client never sees one without the other. +// The published view of a conversation's queue: its whole draft list, the queue's +// pause and the card it sends next, on the `commands` precedent — read per emit, +// reference-stable while unchanged, so the subscribers' identity dedup keeps token +// streams from re-sending it. They ride together: a client never sees one without the others. import { QUEUED_MESSAGE_PAUSED_KEPT, @@ -11,17 +11,40 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' +import { + queuePauseLiftOnItsWay, + resumableQueuePause +} from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' +import { nextStructuredQueuedMessage } from './structured-agent-session-queued-messages' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' export type QueuePublication = { queuedMessages: AgentSessionQueuedMessage[] queuePause: AgentSessionQueuePause | null + /** The card the drain sends next as soon as nothing runs (`nextStructuredQueuedMessage`), so a + * client reads the run as going across a turn's end and that send, which commit apart. */ + nextQueuedMessageId: string | null +} + +/** What the drain's gate reads beyond the journal; resolved per read. */ +export type QueueSendGate = () => { record: AgentSessionRecord | null; fence: number } + +export function structuredQueueSendGate( + store: Pick, + sessionId: string +): QueueSendGate { + return () => ({ + record: store.getRecord(sessionId), + fence: structuredAgentSessionConversationFence(store, sessionId) + }) } /** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion, or a send the host kept); a Stop or a restart pauses the queue, - * published once beside it. */ + * conversion, or a send the host kept); a Stop or a /clear pauses the queue, published once + * beside it. */ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { @@ -86,22 +109,34 @@ export function sameQueuePause( return (previous === null) === (next === null) && previous?.reason === next?.reason } -export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { +export function readQueuePublication( + journal: AgentSessionJournal, + gate: QueueSendGate +): QueuePublication { const queuedMessages = readPublishedQueuedMessages(journal) - // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only - // over a card Resume would send, so its header never offers to send nothing; deleting a - // blocking returned card shows it again. - const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) - const queuePause = pause ? { reason: pause.reason } : null + // Read per emit: the pause also turns on submissions (a turn starting). Shown only over a card + // Resume would send, so its header never offers to send nothing; deleting a blocking returned + // card shows it again. After a restart nothing is shown, a Stop's or /clear's included: the + // chat's next turn lifts every pause, and the cards read as plain waiting cards until then. + const pauses = structuredQueuePauses(journal) + const restarted = pauses.some((pause) => pause.reason === 'restarted') + const resumable = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + // The submissions are read only while a pause would show, never while the queue runs freely. + const pause = + resumable && !queuePauseLiftOnItsWay(resumable, journal.submissions()) ? resumable : null + // `restarted` is already excluded above; the test narrows the type. + const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null + const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) if ( previous && previous.queuedMessages === queuedMessages && - sameQueuePause(previous.queuePause, queuePause) + sameQueuePause(previous.queuePause, queuePause) && + previous.nextQueuedMessageId === nextQueuedMessageId ) { return previous } - const publication = { queuedMessages, queuePause } + const publication = { queuedMessages, queuePause, nextQueuedMessageId } publications.set(journal, publication) return publication } @@ -109,10 +144,11 @@ export function readQueuePublication(journal: AgentSessionJournal): QueuePublica /** For readers that must never fail on drafts — a subscriber stream, a history * page: a closing handle answers "no claim" (absent) instead of throwing. */ export function tryReadQueuePublication( - journal: AgentSessionJournal | undefined + journal: AgentSessionJournal | undefined, + gate: QueueSendGate ): QueuePublication | undefined { try { - return journal ? readQueuePublication(journal) : undefined + return journal ? readQueuePublication(journal, gate) : undefined } catch { return undefined } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts new file mode 100644 index 000000000000..cd8bf2f1870c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts @@ -0,0 +1,36 @@ +// A real quit with cards queued behind a running turn. The queue hands nothing off while the host +// quits, so the chat keeps its restart offer: after the relaunch nothing sends by itself, and +// resuming runs Orca's carry-on first, then the cards in order. + +import { afterEach, expect, it, vi } from 'vitest' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + interruptedRestart, + QUEUED_BEFORE_QUIT +} from './structured-agent-session-restart-interruption-test-harness' + +afterEach(() => vi.restoreAllMocks()) + +it('keeps the restart offer through a quit with cards queued; Resume sends the carry-on, then the cards in order', async () => { + const { host, dispatch } = await interruptedRestart('queued-cards') + // Nothing was handed off while the host quit. + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions.filter((entry) => entry.queuedMessageId !== undefined)).toEqual([]) + expect(await host.restartResume.list()).toHaveLength(1) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuePause).toBeFalsy() + expect(page.ok && page.page.queuedMessages?.map((card) => card.state)).toEqual([ + 'waiting', + 'waiting' + ]) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(dispatch).not.toHaveBeenCalled() + + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(resumed.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) + await vi.waitFor(() => expect(dispatch.mock.calls.length).toBeGreaterThanOrEqual(3)) + const sent = dispatch.mock.calls.map((call) => JSON.stringify(call[0])) + expect(sent[0]).not.toContain('card') + expect(sent[1]).toContain(QUEUED_BEFORE_QUIT[0]) + expect(sent[2]).toContain(QUEUED_BEFORE_QUIT[1]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 1b394ebaf558..0fa49460d5e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -1,6 +1,5 @@ -// A send's queue step around its immediate path: the queue decision before it. A -// person's send lifts a paused queue through its recorded origin once its turn -// starts (`structured-agent-session-queued-pause.ts`), not through anything here. +// A send's queue step around its immediate path: the queue decision before it. Its +// accepted turn lifts a paused queue (`queued-message-pause.ts`), not anything here. import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index d1db51426c50..4e45ab4a6cc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,7 +1,7 @@ // Stop writes one event row before it interrupts, and the queue's pause is derived from it: // through the real host, the cards queued before a Stop wait, a card queued after it sends -// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, it -// never hides a restart's pause, and no stored pause is ever written. +// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, any +// turn sent after it ends it, and no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -45,17 +45,17 @@ function journal(sessionId = HOST_TEST_SESSION) { return open } -/** No drain step converts the cards, and the published pause names `reason`. */ -async function expectHeld(reason: string, ...draftIds: string[]): Promise { +/** No drain step converts the cards, and the published pause names `reason` (null: none shown). */ +async function expectHeld(reason: string | null, ...draftIds: string[]): Promise { await new Promise((resolve) => setTimeout(resolve, 250)) for (const draftId of draftIds) { expect(await rig.handoff(draftId)).toBeUndefined() } - expect(await rig.queuePause()).toEqual({ reason }) + expect(await rig.queuePause()).toEqual(reason === null ? null : { reason }) } async function mailTurn(): Promise { - const mail = rig.send('coordinator mail', undefined, { internal: true }) + const mail = rig.send('coordinator mail') await mail.result await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) return mail.id @@ -109,10 +109,8 @@ describe("Stop's event", () => { const working = await rig.workingSend() const held = await queuedDraft('queued before the stop') await rig.stop() + const later = await queuedDraft('typed while the stopped turn winds down') await rig.settleAccepted(working, 'stopped') - const mail = await mailTurn() - const later = await queuedDraft('queued during the mail turn') - await rig.settleAccepted(mail, 'mail') // The queue never reorders: the newer card waits behind the held one, with no caption of its own. await expectHeld('stopped', held, later) expect(await rig.drafts()).toEqual([ @@ -127,12 +125,11 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) }) - it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { + it("any accepted turn sent after it lifts it, Orca's own mail included; a later Stop supersedes", async () => { const working = await rig.workingSend() const first = await queuedDraft('first') await rig.stop() await rig.settleAccepted(working, 'stopped') - await rig.settleAccepted(await mailTurn(), 'mail') await expectHeld('stopped', first) const person = rig.send('the person asks for a turn') await person.result @@ -140,9 +137,11 @@ describe("Stop's event", () => { await rig.stop() await rig.settleAccepted(person.id, 'person') await expectHeld('stopped', first) - const after = rig.send('asked after the second Stop') - await after.result - await rig.settleAccepted(after.id, 'after') + // Orchestration mail sent after the second Stop: once accepted, the queue carries on. + const mail = await mailTurn() + // Not shown while the mail awaits the agent, and nothing sends yet. + await expectHeld(null, first) + await rig.settleAccepted(mail, 'mail') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) }) @@ -162,14 +161,12 @@ describe("Stop's event", () => { const sends = (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions.filter( (entry) => entry.queuedMessageId === sentId ) - expect(sends.map((entry) => [entry.origin, entry.dispatchState])).toEqual([ - ['client', 'rejected'] - ]) + expect(sends.map((entry) => entry.dispatchState)).toEqual(['rejected']) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + await eventually(async () => expect(await rig.handoffId(sentId)).not.toBe(handoffId)) }) - it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { + it('survives a host crash, unshown: the next open marks the hand-off unknown, nothing sends, Resume releases', async () => { await rig.workingSend() const sentId = await queuedDraft('sent now into the turn') const waiting = await queuedDraft('waiting behind it') @@ -179,37 +176,42 @@ describe("Stop's event", () => { // The process dies with no close; a new host opens the same state directory. rig.crashRestartHostProcess() expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') - await expectHeld('stopped', waiting) + // After a restart no row shows, the Stop's included, and nothing sends. + await expectHeld(null, waiting) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ + 'stopped', + 'restarted' + ]) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(waiting)).toBeDefined()) }) }) -describe("a Stop never hides a restart's pause", () => { - it('a card queued after a Stop over an empty queue, before a restart, waits restarted', async () => { +describe("a restart's hold over a card queued after a Stop", () => { + it('a card queued after a Stop over an empty queue, before a restart, waits unshown', async () => { const working = await rig.workingSend() await rig.stop() await rig.settleAccepted(working, 'stopped') const mail = await mailTurn() const typed = await queuedDraft('typed during the mail turn') - // The process dies with no close: a quit writes no Stop event either, so the Stop's pause stays. + // The process dies with no close: a quit writes no Stop event, so only a turn ends the pauses. rig.crashRestartHostProcess() await rig.settleAccepted(mail, 'mail') - // The new host opens the conversation for its first reader. + // The new host opens the conversation for its first reader. The card came after the Stop, so + // only the restart holds it, and a restart's hold is never published. await rig.queuePause() expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ 'stopped', 'restarted' ]) - await expectHeld('restarted', typed) + await expectHeld(null, typed) }) - it("a card queued during the queue's own send after a Stop, before a restart, waits restarted", async () => { + it("a card queued during the queue's own send after a Stop, before a restart, waits unshown", async () => { const working = await rig.workingSend() await rig.stop() const correction = await queuedDraft('typed while the interrupt lands') await rig.settleAccepted(working, 'stopped') - // The queue's own send is not a person's turn: the Stop stays in force, holding nothing. await eventually(async () => expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() ) @@ -217,11 +219,8 @@ describe("a Stop never hides a restart's pause", () => { rig.crashRestartHostProcess() await rig.settleAccepted(await rig.handoffId(correction), 'correction') await rig.queuePause() - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ - 'stopped', - 'restarted' - ]) - await expectHeld('restarted', typed) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) + await expectHeld(null, typed) }) }) @@ -234,7 +233,7 @@ describe("a /clear's carried cards", () => { }) } - it("wait 'cleared' on the replacement until a person's turn there", async () => { + it("wait 'cleared' on the replacement until a turn there", async () => { const working = await rig.workingSend() const carried = await queuedDraft('written for the old context') await rig.stop() @@ -254,8 +253,7 @@ describe("a /clear's carried cards", () => { hostTestOperationId(), replacementId ), - body: text, - userSend: true + body: text }) const sent = await person if (!sent.ok || !('submission' in sent.value)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 03c1042309fa..964cefc1b2f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -1,8 +1,8 @@ // A Stop's event and the queue. A Stop never withdraws a draft and no text ever // travels back over the wire: where it takes effect it appends ONE Stop event // (`JournalStopEvent`), and the queue's pause is derived from it -// (`queued-message-pause.ts`) until a turn a person asked for is sent after it, or -// they Resume. The cards stay published, and Send-now sends one card without lifting +// (`queued-message-pause.ts`) until any turn is sent after it and accepted, or the +// person Resumes. The cards stay published, and Send-now sends one card without lifting // the pause for the rest until that card's turn starts. The event is bookkeeping: a // failure is reported and never gates the interrupt. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index 6fd6bb136125..af7aee44bc98 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -40,7 +40,7 @@ export function wireStructuredAgentSessionQueuedMessages( drain, /** Every journal publish: turn, submission, prompt, command and Stop * settlements are all commits, and each re-derives the drain's gates — - * and adopts a restart's cards once a person's turn started. */ + * and adopts a restart's cards once a turn started. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 5cc9f7d39c0a..e17315b43e83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -42,6 +42,10 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import type { StructuredAgentRegistry } from './structured-agent-registry' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' + +/** What `interruptedRestart('queued-cards')` queues behind the running turn, in order. */ +export const QUEUED_BEFORE_QUIT = ['card A', 'card B'] as const /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -57,7 +61,8 @@ export async function startAgent(state: { } export async function interruptedRestart( - work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', + /** 'queued-cards': a running turn with `QUEUED_BEFORE_QUIT` queued behind it. */ + work: 'turn' | 'submission' | 'send-after-reply' | 'children' | 'queued-cards' = 'turn', historyBoundaryConsistent = true, /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ probeOwner: NonNullable = async () => ({ @@ -129,7 +134,22 @@ export async function interruptedRestart( ) } await previous.host.flushStreamedEvents(SESSION) + if (work === 'queued-cards') { + for (const text of QUEUED_BEFORE_QUIT) { + const body = hostTestMessage(text) + const fields = { body, delivery: 'queue-if-active' as const } + const queued = await previous.host.send(CALLER, { + envelope: envelope('agentSession.send', fields), + ...fields + }) + expect(queued).toMatchObject({ ok: true, value: { queued: expect.anything() } }) + } + } await previous.host.flushAllStreamedEvents() + if (work === 'queued-cards') { + // The relaunch is a new process: the cards it finds were written by the one that quit. + rotateStructuredAgentSessionHostInstanceForTests() + } const store = await openTestAgentSessionRecordStore(previous.root) const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. @@ -170,18 +190,16 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } -/** The continuation's submission commits, then its send throws: a send Orca may have taken. */ +/** The continuation's submission commits, then its send throws: a send Orca may have taken. + * Install it right before the continuation, the only submission written from then on. */ export function throwAfterContinuationAccepted(): void { const append = AgentSessionJournal.prototype.appendSubmission vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementation(async function ( this: AgentSessionJournal, ...args: Parameters ) { - const cursor = await append.apply(this, args) - if (args[0].origin === 'host') { - throw new Error('the accepted continuation could not be answered') - } - return cursor + await append.apply(this, args) + throw new Error('the accepted continuation could not be answered') }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 65da28ca6b2c..8d46b360b9fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -121,7 +121,7 @@ async function stopOfStart(options: { held?: true } = {}): Promise { - const mail = rig.send('mail for the worker', undefined, { internal: true }) + const mail = rig.send('mail for the worker') await mail.result await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) await turnOpenedBy(mail.id) @@ -158,7 +158,7 @@ describe('a Stop of a start that never landed binds no later turn', () => { it('reads a mail turn the child end cut, with no verdict of its own, as news', async () => { await stopOfStart() - const mail = rig.send('mail for the worker', undefined, { internal: true }) + const mail = rig.send('mail for the worker') await mail.result await turnOpenedBy(mail.id) @@ -266,7 +266,7 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st it('reads a mail turn the child end cut as news', async () => { const { stopped } = await stopBeforeTheTurnShowed() await rig.settleAccepted(stopped, 'stopped') - const mail = rig.send('mail for the lead', undefined, { internal: true }) + const mail = rig.send('mail for the lead') await mail.result await turnOpenedBy(mail.id) @@ -392,7 +392,7 @@ describe('a host stop with no turn running after a Stop that named none', () => rig = await createQueuedMessageTestRig() await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) - const mail = rig.send('mail for the lead', undefined, { internal: true }) + const mail = rig.send('mail for the lead') await mail.result await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() @@ -432,7 +432,7 @@ describe('a rewind that restates a turnless Stop', () => { body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } } ]) - const mail = rig.send('mail after the rewind', undefined, { internal: true }) + const mail = rig.send('mail after the rewind') await mail.result await turnOpenedBy(mail.id) await turnOpenedBy(mail.id, 'interrupted') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 1d7781c98869..aa9ff4d041d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -14,6 +14,7 @@ import { eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -389,14 +390,14 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - // Orchestration mail starts a turn the host sent, which lifts nothing. - await rig.send('mail for the lead', undefined, { internal: true }).result + // Orchestration mail's turn runs, but its send is not accepted yet, so it lifts nothing yet. + await rig.send('mail for the lead').result await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['stopped']) const atClose = stopEventsAtClose() await rig.host.close(HOST_TEST_SESSION, 'evict') @@ -419,7 +420,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) holdStart() - rig.send('mail for the lead', undefined, { internal: true }) + rig.send('mail for the lead') await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( 'starting' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 916654cac648..55eb9080a5b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -321,8 +321,8 @@ describe('a restart between a Stop and its turn end', () => { scope ) expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) - // A send after the Stop, the queue's drain here, opens its own turn. - const drained = rig.send('drained after the Stop', undefined, { internal: true }) + // A host send after the Stop opens its own turn. + const drained = rig.send('drained after the Stop') await drained.result await rig.settleAccepted(drained.id, 'drained') await journal().appendItem( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 1ceff1b63a44..2a8c496e8b2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -277,7 +277,8 @@ describe("a Stop's event", () => { // Stop and the drain share one serialized lane, so this interleaving is forced: a pause // written after the drain chose the card must still hold it in the claim's transaction. vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { - if (input.origin === 'host' && consume?.messageId === draftId && !injected) { + // The queue's own claim is the one no operation settles. + if (consume?.settledByOp === null && consume.messageId === draftId && !injected) { injected = true await open.appendStopEvent({ reason: 'user-stop' }, input.fence) } 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 2885cf1d4c8a..defcc402af8c 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 @@ -311,10 +311,10 @@ 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. +// settle: the queued cards come back as a crash leaves them, under the restart's hold. 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', + 'after a %s they wait under the restart hold, not kept, with no hand-off made', async (how) => { await rig.workingSend() const first = rig.send('queued behind work', 'queue-if-active') @@ -337,7 +337,8 @@ describe('cards queued behind a working turn, then Orca stops', () => { { messageId: first.id, state: 'waiting' }, { messageId: second.id, state: 'waiting' } ]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + // The restart's hold is never published; the cards still wait. + expect(await rig.queuePause()).toBeNull() expect(await rig.handoff(first.id)).toBeUndefined() expect(journal().queuedMessages.get(first.id)?.holdReason).toBeNull() } @@ -406,7 +407,7 @@ describe('the queue at a quit', () => { rig.crashRestartHostProcess() expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() expect(await rig.handoff(queued.id)).toBeUndefined() expect(rig.dispatch).not.toHaveBeenCalled() } @@ -428,7 +429,7 @@ describe('the queue at a quit', () => { { messageId: pushed.id, ...KEPT }, { messageId: first.id, state: 'waiting' } ]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() await rig.resume() await eventually(() => expect(dispatchedTexts()).toEqual(['first queued'])) await new Promise((resolve) => setTimeout(resolve, 100)) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index e420176c70cc..ed989fb83e83 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -37,12 +37,6 @@ describe('agentSession.send reply timing', () => { }) }) - it("marks a client's send as the user's own, which alone lifts a Stop's queue pause", async () => { - hostCalls.send.mockResolvedValueOnce(pendingSendResult()) - await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) - expect(hostCalls.send.mock.calls[0]?.[1]).toMatchObject({ userSend: true }) - }) - it('answers at acceptance for the local desktop and paired desktop clients (W2)', async () => { for (const clientCapabilities of [ DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 47afaa8ac735..019c0d63453f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -20,7 +20,7 @@ export async function sendStructuredAgentSessionForClient( context: RpcContext ) { const host = requireStructuredHost(context) - // Only a client's own send lifts a Stop's queue pause; host-internal senders never do. + // A client's own send: a restart or a close keeps it as a card if it never reached the agent. const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) const capabilities = context.clientCapabilities ?? [] if ( diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index c268c7c8e85d..ce9621aefe28 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -134,8 +134,7 @@ async function queueThenSendNow(text: string): Promise<{ messageId: string; sent const queued = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery, - userSend: true + delivery }) if (!queued.ok || !('queued' in queued.value)) { throw new Error(`expected a queued card: ${JSON.stringify(queued)}`) @@ -424,10 +423,9 @@ describe('a queued card sent now into the turn a Stop ends', () => { return (await settled()).submissions.filter((entry) => entry.queuedMessageId === messageId) } - /** Each hand-off of the card, as who sent it and how it settled. */ + /** Each hand-off of the card, as how it settled. */ async function sends(messageId: string) { return (await handoffs(messageId)).map((entry) => ({ - origin: entry.origin, verdict: verdictOf([entry], entry.clientMessageId) })) } @@ -465,7 +463,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { expect({ ...(await queue()), sends: await sends(cardId) }).toEqual({ pause: { reason: 'stopped' }, cards: [{ messageId: cardId, state: 'waiting' }], - sends: [{ origin: 'client', verdict: 'withdrawn' }] + sends: [{ verdict: 'withdrawn' }] }), { timeout: 5_000 } ) @@ -480,7 +478,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { clock.restore() } expect(steers + answers).toBe(2) - expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) + expect(await sends(cardId)).toEqual([{ verdict: 'withdrawn' }]) }, 20_000) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index e4e9e97659a5..ead71a2a0ff2 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -30,7 +30,7 @@ import type { NativeChatComposerProps } from './native-chat-composer-types' import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' -import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' +import { useNativeChatHeldQueueComposerSend } from './use-native-chat-held-queue-composer-send' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection' import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop' @@ -245,7 +245,7 @@ const NativeChatComposerPane = forwardRef { const props = { attachDisabled: false, dictationDisabled: false, + primaryAction: 'stop' as const, isWorking: true, isDictating: false, isDictationHoldMode: false, @@ -65,6 +66,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="send" isWorking={false} isDictating={false} isDictationHoldMode={false} @@ -89,6 +91,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="stop" isWorking isDictating={false} isDictationHoldMode={false} @@ -118,6 +121,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="stop" isWorking isDictating={false} isDictationHoldMode={false} @@ -144,6 +148,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled + primaryAction="send" sendBlockedReason="An image couldn't be brought back. Remove it to send." isWorking={false} isDictating={false} @@ -169,6 +174,7 @@ describe('NativeChatComposerActions', () => { attachDisabled: false, dictationDisabled: false, sendDisabled: false, + primaryAction: 'send' as const, isWorking: false, isDictating: false, isDictationHoldMode: false, @@ -188,4 +194,53 @@ describe('NativeChatComposerActions', () => { expect(screen.getByRole('img', { name: explanation })).toBeTruthy() expect(screen.getByRole('button', { name: 'Send' }).hasAttribute('disabled')).toBe(false) }) + + describe('Resume of a held queue', () => { + function renderPrimary(isWorking: boolean, onResume: (() => void) | undefined) { + const callbacks = { onSend: vi.fn(), onStop: vi.fn() } + render( + + ) + return callbacks + } + + it("takes Send's place: one press resumes, never sends", () => { + const onResume = vi.fn() + const { onSend } = renderPrimary(false, onResume) + expect(screen.queryByRole('button', { name: 'Send' })).toBeNull() + const resume = screen.getByRole('button', { name: 'Resume' }) + fireEvent.click(resume, { detail: 1 }) + fireEvent.click(resume, { detail: 2 }) + expect(onResume).toHaveBeenCalledTimes(1) + expect(onSend).not.toHaveBeenCalled() + }) + + it('is Send without it, and Stop while a turn runs', () => { + renderPrimary(false, undefined) + expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy() + cleanup() + const onResume = vi.fn() + const { onStop } = renderPrimary(true, onResume) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Stop the agent' }), { detail: 1 }) + expect(onStop).toHaveBeenCalledTimes(1) + expect(onResume).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 67472922904a..2bcea04f8773 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -1,4 +1,4 @@ -import { ArrowUp, CircleAlert, Mic, Plus, Square } from 'lucide-react' +import { ArrowUp, CircleAlert, Mic, Play, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' @@ -11,11 +11,14 @@ import { NativeChatComposerGoalChip } from './NativeChatComposerGoalChip' import { NativeChatContextUsageRing } from './NativeChatContextUsageRing' import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary' import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' +import type { NativeChatComposerPrimaryAction } from './native-chat-composer-primary-action' export type NativeChatComposerActionsProps = { attachDisabled: boolean dictationDisabled: boolean sendDisabled: boolean + /** What the primary button does (`nativeChatComposerPrimaryAction`). */ + primaryAction: NativeChatComposerPrimaryAction /** Shown on the disabled send button: what the user can do to send. */ sendBlockedReason?: string | null /** Storage refused this draft; it is held in memory only. */ @@ -31,6 +34,8 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + /** Releases the held queue when the primary action is Resume. */ + onResume?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null @@ -44,6 +49,7 @@ export function NativeChatComposerActions({ attachDisabled, dictationDisabled, sendDisabled, + primaryAction, sendBlockedReason, draftNotSaved, isWorking, @@ -56,20 +62,25 @@ export function NativeChatComposerActions({ onDictationHoldEnd, onSend, onStop, + onResume, sessionOptionsSurface, sessionOptionsSnapshot, sessionOptionsPickerRequest, onExitGoalMode, contextUsage }: NativeChatComposerActionsProps): React.JSX.Element { + const stops = primaryAction === 'stop' + const resumes = primaryAction === 'resume' const handleCriticalAction = (event: React.MouseEvent): void => { // A double-click commonly lands after the first send has started and the button has // changed to Stop; ignore the second click instead of cancelling the new turn. if (event.detail > 1) { return } - if (isWorking) { + if (stops) { onStop?.() + } else if (resumes) { + onResume?.() } else { onSend() } @@ -77,25 +88,33 @@ export function NativeChatComposerActions({ const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') - const sendReason = isWorking ? null : (sendBlockedReason ?? null) + const sendReason = stops || resumes ? null : (sendBlockedReason ?? null) const sendButton = ( ) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index c67af9fbedcb..d9069c5af631 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -19,7 +19,13 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' +import { + nativeChatComposerPrimaryButton, + type NativeChatQueueResume +} from './native-chat-composer-primary-action' import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' +import { NativeChatQueueSendConfirmDialog } from './NativeChatQueueSendConfirmDialog' +import type { NativeChatQueueSendConfirm } from './use-native-chat-held-queue-composer-send' import type { NativeChatComposerGoalMode } from './use-native-chat-composer-submit' import { translate } from '@/i18n/i18n' import { useNativeChatComposerDraftUnsaved } from './use-native-chat-draft-unsaved' @@ -69,6 +75,8 @@ export type NativeChatComposerFieldProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + queueResume?: NativeChatQueueResume | undefined + queueSendConfirm?: NativeChatQueueSendConfirm | null sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] contextUsage?: NativeChatContextUsageSummary | null @@ -149,6 +157,8 @@ export function NativeChatComposerField({ onDictationHoldEnd, onSend, onStop, + queueResume, + queueSendConfirm = null, sessionOptionsSurface, sessionOptionsSnapshot, contextUsage, @@ -188,6 +198,20 @@ export function NativeChatComposerField({ onImeSettled(element) } + const primary = nativeChatComposerPrimaryButton({ + isWorking, + composerEmpty: draft.trim() === '' && imageAttachments.length === 0, + queueResume, + composerDisabled: disabled, + sendDisabled: sendButtonDisabled + }) + const { resume } = primary + // The button disables while resuming, which drops its focus; typing is what comes next. + const resumeQueue = (): void => { + resume?.() + textareaRef.current?.focus() + } + return (
{/* Extra bottom padding keeps the input box off the window rim. */} @@ -304,7 +328,8 @@ export function NativeChatComposerField({
+ textareaRef.current?.focus()} + /> ) } diff --git a/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx new file mode 100644 index 000000000000..b2cdc032f785 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx @@ -0,0 +1,65 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { NativeChatQueueSendConfirm } from './use-native-chat-held-queue-composer-send' + +/** Asks, before a message sent over a held queue, whether to clear the cards first. */ +export function NativeChatQueueSendConfirmDialog({ + confirm, + focusComposer +}: { + confirm: NativeChatQueueSendConfirm | null + /** Where focus goes whichever way it closes. */ + focusComposer: () => void +}): React.JSX.Element | null { + if (!confirm) { + return null + } + return ( + { + if (!isOpen) { + confirm.dismiss() + } + }} + > + { + event.preventDefault() + focusComposer() + }} + > + + + {translate('components.native-chat.queuedMessages.sendConfirmTitle', 'Send message?')} + + + {translate( + 'components.native-chat.queuedMessages.sendConfirmBody', + 'You are about to send a message. Do you want to clear the {{count}} messages previously queued?', + { count: confirm.count } + )} + + + + + + + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index b0c2eded879f..d9dad7cb8291 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -26,7 +26,10 @@ import { 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' +import { + queuedMessageCardSteers, + type QueuedMessageCard +} from './structured-agent-session-queued-cards' import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' /** The visible caption under the text; the default waiting hold needs none. */ @@ -88,15 +91,14 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null } } -/** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own - * or returned is not waiting on the turn, so its action and tooltip are plainly Send. */ +/** Steer's or Send's label and tooltip (`queuedMessageCardSteers`). */ export function queuedMessageCardSendNow(card: QueuedMessageCard): { /** Steer's ↳, or Send's paper plane. */ steers: boolean label: string hint: string } { - if (card.hold === 'paused' || card.hold === 'returned') { + if (!queuedMessageCardSteers(card)) { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 973fc1c7866c..65150b38c631 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('../../store', () => { const state = { updateSettings: mocks.updateSettings } const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) @@ -23,7 +24,13 @@ import { TooltipProvider } from '@/components/ui/tooltip' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' -import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' +import { + useStructuredAgentSessionQueuedMessages, + type StructuredAgentSessionQueuedMessagesController +} from './use-structured-agent-session-queued-messages' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import type { AgentSessionQueuePause } from '../../../../shared/agent-session-queued-message-wire' function renderList(owner: StructuredAgentSessionQueuedMessagesController) { // The app root mounts the provider; tests supply the same context. @@ -63,10 +70,55 @@ function controller( steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - steerNewest: vi.fn(() => false) + steerNewest: vi.fn(() => false), + queueResume: undefined, + queueHold: undefined } } +function waitingDraft( + messageId: string, + position: number, + overrides: Partial = {} +): AgentSessionQueuedMessage { + return { + messageId, + position, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `${messageId} text` }] }, + state: 'waiting', + ...overrides + } +} + +/** The list over the real controller: cards and header as the host's publication projects them. + * Nothing runs after a Stop or a /clear. */ +function renderHeldQueue( + queuedMessages: AgentSessionQueuedMessage[], + queuePause: AgentSessionQueuePause | null +) { + const mutate = vi.fn(async (..._call: [string, string, Record]) => null) + function HeldQueue(): React.JSX.Element { + const owner = useStructuredAgentSessionQueuedMessages({ + enabled: true, + queuedMessages, + queuePause, + submissions: [], + hasPendingPrompt: false, + isWorking: false, + composerScopeKey: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read. + mutate: mutate as StructuredAgentSessionMutate + }) + return + } + const view = render( + + + + ) + return { ...view, mutate } +} + beforeEach(() => { mocks.updateSettings.mockReset() }) @@ -251,46 +303,62 @@ describe('NativeChatQueuedMessageList', () => { ) }) - it('a paused queue shows one header row per reason above the cards, with Resume', () => { - const cases = [ - ['stopped', 'Queue paused because you interrupted'], - ['restarted', 'Queue paused because Orca restarted'], - ['cleared', 'Queue paused after you cleared the conversation'], - ['some-newer-reason', 'Queue paused'] - ] as const - for (const [reason, text] of cases) { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason }) - const view = renderList(owner) - expect(view.container.textContent).toContain(text) + it.each([ + ['stopped', 'Queue paused because you interrupted'], + ['cleared', 'Queue paused after you cleared the conversation'], + ['some-newer-reason', 'Queue paused'] + ])( + "a queue the host holds ('%s') shows one header row above the cards, with Resume; the held card still reads Steer", + (reason, text) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. + const pause = { reason } as AgentSessionQueuePause + const { container, mutate } = renderHeldQueue([waitingDraft('held', 1)], pause) + expect(container.textContent).toContain(text) + const list = screen.getByRole('list') // The row sits above the list, not inside it: it is not a queued message. - expect(within(screen.getByRole('list')).queryByText(text)).toBeNull() + expect(within(list).queryByText(text)).toBeNull() + const row = within(list).getByRole('listitem') + // The text is the card's only line: the header carries the why. + expect(row.querySelectorAll('p')).toHaveLength(1) + expect(within(row).queryByRole('button', { name: 'Send' })).toBeNull() + expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Resume' })) - expect(owner.resume).toHaveBeenCalledTimes(1) - view.unmount() + expect(mutate).toHaveBeenCalledWith( + 'agentSession.queuedMessagesResume', + 'agentSession.queuedMessagesResume', + {} + ) } + ) + + it('a paused queue holds every card, in order, under one header', () => { + renderHeldQueue([waitingDraft('a', 1), waitingDraft('b', 2)], { reason: 'stopped' }) + expect(screen.getByText('Queue paused because you interrupted')).toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Steer' })).toHaveLength(2) }) - it('no header row when the queue drains on its own, or when there are no cards', () => { - renderList(controller([card({ messageId: 'waiting' })], null)) + it('no header row when the queue drains on its own (or after a restart), or with no cards', () => { + renderHeldQueue([waitingDraft('waiting', 1)], null) + expect(screen.queryByText(/Queue paused/)).toBeNull() expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() cleanup() - const { container } = renderList(controller([], { reason: 'stopped' })) + const { container } = renderHeldQueue([], { reason: 'stopped' }) expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() expect(container.textContent).toBe('') }) it('no header row over cards Resume would not send: returned, held on their own, or behind', () => { - renderList( - controller( - [ - card({ messageId: 'refused', state: 'returned', hold: 'returned', returnedReason: null }), - card({ messageId: 'behind', hold: 'behind-returned', position: 2 }), - card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) - ], - { reason: 'stopped' } - ) + const stopped = { reason: 'stopped' } as const + const { container } = renderHeldQueue( + [ + waitingDraft('refused', 1, { state: 'returned', returnedReason: null }), + waitingDraft('behind', 2), + waitingDraft('failed', 3, { paused: true, pausedReason: 'send_failed' }) + ], + stopped ) expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + expect(container.textContent).not.toContain('Queue paused') }) it('Resume shows it is pending, keeps the full text for a truncated line, and hands focus back', async () => { @@ -319,6 +387,7 @@ describe('NativeChatQueuedMessageList', () => { const enabled = screen.getByRole('button', { name: 'Resume' }) enabled.focus() fireEvent.click(enabled) + expect(idle.resume).toHaveBeenCalledTimes(1) await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) }) @@ -414,8 +483,8 @@ describe('NativeChatQueuedMessageList', () => { expect(owner.steer).toHaveBeenCalledWith('waiting') }) - it('only a card still waiting on the turn promises to skip the wait', () => { - for (const hold of ['turn', 'awaiting-answer', 'behind-returned'] as const) { + it('every card still waiting on the queue reads Steer; one held on its own or returned, Send', () => { + for (const hold of ['turn', 'awaiting-answer', 'behind-returned', 'queue-paused'] as const) { expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ steers: true, label: 'Steer', @@ -461,9 +530,8 @@ describe('NativeChatQueuedMessageList', () => { // 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' - }) + // The controller names no pause over a card held on its own (`queuedMessagesQueuePause`). + controller([card({ messageId: 'held', hold: 'paused', pausedReason: 'kept' })], null) ) const [row] = screen.getAllByRole('listitem') expect(row?.textContent).toContain('Not sent yet — press Send to send it.') diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 2007bb1900e7..460b15c886a0 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -24,15 +24,12 @@ export function NativeChatQueuedMessageList({ }): React.JSX.Element { const updateSettings = useAppStore((store) => store.updateSettings) const queueRef = useRef(null) - const { cards } = controller + const { cards, pause } = controller const newest = cards.at(-1) // Only a host that queues sends has queueing to turn off; a kept card shows without it. const turnOffQueueing = controller.queueCapable ? () => void updateSettings({ nativeChatQueueFollowUps: false }) : undefined - // A pause over cards Resume would not send (returned, held on their own, or behind a returned - // one) offers nothing to press. - const pause = cards.some((card) => card.hold === 'queue-paused') ? controller.pause : null // Only when focus was on the queue (a card, or Resume) — never pull it from wherever the user // moved on to. const refocusAfter = (action: Promise): void => { @@ -91,11 +88,6 @@ function queuePauseText(pause: { reason: string }): string { 'components.native-chat.queuedMessages.queuePausedStopped', 'Queue paused because you interrupted' ) - case 'restarted': - return translate( - 'components.native-chat.queuedMessages.queuePausedRestarted', - 'Queue paused because Orca restarted' - ) case 'cleared': return translate( 'components.native-chat.queuedMessages.queuePausedCleared', diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 50fb40590945..5f4fdb95e608 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -115,6 +115,7 @@ const DEFAULT_FILE_LINK_CONTEXT: NativeChatFileLinkContext = { const initialMessageListProps: StructuredSessionMessageListProps | null = null const initialApprovalCardProps: NativeChatApprovalCardProps | null = null +type QueueResumeMock = { resume: () => void; resuming: boolean } /** * Shared mock state and `vi.mock` factories for the NativeChatStructuredSession test files. @@ -140,7 +141,7 @@ export function createStructuredSessionMocks() { messageListProps: initialMessageListProps, composerProps: nullable<{ launchSeed?: NativeChatLaunchSeed - structuredTransport?: Record + structuredTransport?: Record & { queueResume?: QueueResumeMock } isWorking?: boolean isStopping?: boolean afterStop?: 'queue' | 'send' @@ -180,6 +181,9 @@ export function createStructuredSessionMocks() { queuedRemove: vi.fn<(messageId: string) => Promise>(async () => {}), queuedEdit: vi.fn<(messageId: string) => Promise>(async () => {}), queuedSteerNewest: vi.fn<() => boolean>(() => false), + queuedResumable: false, + queueSendsNext: false, + queuedResume: vi.fn<() => Promise>(async () => true), revealLatest: vi.fn<() => void>() } @@ -256,6 +260,7 @@ export function createStructuredSessionMocks() { epoch: 'epoch-1', rewind: { surface: undefined }, canStop: mocks.canStop ?? mocks.turnId !== null, + queueSendsNext: mocks.queueSendsNext, stopPressed: mocks.stopPressed, sendsQueue: mocks.sendsQueue, stop: mocks.stop, @@ -264,7 +269,10 @@ export function createStructuredSessionMocks() { steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, - steerNewest: mocks.queuedSteerNewest + steerNewest: mocks.queuedSteerNewest, + queueResume: mocks.queuedResumable + ? { resume: mocks.queuedResume, resuming: false } + : undefined }, threadGoal: mocks.threadGoal, cancel: mocks.cancel, @@ -409,6 +417,8 @@ export function createStructuredSessionMocks() { mocks.loadingOlder = false mocks.olderHistoryGeneration = 0 mocks.loadOlder.mockReset() + Object.assign(mocks, { queuedResumable: false, queueSendsNext: false }) + mocks.queuedResume.mockReset() mocks.revealLatest.mockReset() mocks.queuedSteerNewest.mockReset() mocks.queuedSteerNewest.mockReturnValue(false) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 79ad33de40a1..410aaff4160b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -513,6 +513,24 @@ describe('NativeChatStructuredSession', () => { expect(mocks.cancel).not.toHaveBeenCalled() }) + it("shows the queue's coming send as a Stop that is not live until a turn can be stopped", () => { + mocks.queueSendsNext = true + render(claudeSessionView('structured-tab-sends-next', 'session-sends-next')) + expect(mocks.composerProps?.isWorking).toBe(true) + expect(mocks.composerProps?.onStop).toBeUndefined() + }) + + it('hands the composer Resume, on its transport, only while the queue controller offers it', () => { + const { rerender } = render(claudeSessionView('structured-tab-resume', 'session-resume')) + expect(mocks.composerProps?.structuredTransport?.queueResume).toBeUndefined() + mocks.queuedResumable = true + rerender(claudeSessionView('structured-tab-resume', 'session-resume')) + act(() => { + mocks.composerProps?.structuredTransport?.queueResume?.resume() + }) + expect(mocks.queuedResume).toHaveBeenCalledOnce() + }) + it('keeps the strip mounted through a running turn, with the turn owning the voice', () => { // The strip stands for work that OUTLIVES a turn, so `show` is true while // `isMonitoring` is false: mounted, but not speaking as the live indicator. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2b8c7bce72b9..fd57f38be7fa 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -42,6 +42,8 @@ import { structuredAgentSessionReadFailureNotice } from './structured-agent-sess import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' +type OptionPickerRequest = { id: string; sequence: number } + export function NativeChatStructuredSession( props: Omit ): React.JSX.Element { @@ -80,10 +82,7 @@ export function NativeChatStructuredSession( transcriptLoading: controller.status === 'idle' || controller.status === 'loading' }) const [composerError, setComposerError] = useState(null) - const [optionPickerRequest, setOptionPickerRequest] = useState<{ - id: string - sequence: number - } | null>(null) + const [optionPickerRequest, setOptionPickerRequest] = useState(null) const rootRef = useRef(null) const paneCommands = useStructuredNativeChatPaneCommands({ tabId: props.tabId, @@ -229,7 +228,9 @@ export function NativeChatStructuredSession( runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', sessionId: props.sessionId, runtimeEnvironmentId: - props.target.kind === 'local' ? null : (props.target.environmentId ?? null) + props.target.kind === 'local' ? null : (props.target.environmentId ?? null), + queueHold: submits.queuedMessages.queueHold, + queueResume: submits.queuedMessages.queueResume } }, [ controller, @@ -239,7 +240,8 @@ export function NativeChatStructuredSession( props.sessionId, props.target, revealLatest, - sendThroughRelaunch + sendThroughRelaunch, + submits.queuedMessages ]) return ( @@ -399,7 +401,6 @@ export function NativeChatStructuredSession( draftScopeKey={structuredAgentSessionDraftScopeKey(props.sessionId)} targetPtyId={null} agent={props.agent} - isWorking={controller.canStop} {...stopControls.composer} steerQueued={stopControls.stopping ? undefined : submits.queuedMessages.steerNewest} structuredTransport={structuredTransport} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index d36cc874b530..ebd56bd94653 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -98,10 +98,12 @@ vi.mock('./use-structured-agent-session', async () => { cancel: vi.fn(), queuedMessages: { cards: [], + turnRunning: false, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - steerNewest: () => false + steerNewest: () => false, + queueResume: undefined }, stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId), respond: mocks.respond, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 13e4bde85030..adedcc4d856c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -162,7 +162,8 @@ function composer( setDraft, setCaret }) - onRender({ draft, setDraft, attachments, send }) + // The composer fires a send and moves on; each test settles it through the transport. + onRender({ draft, setDraft, attachments, send: (text) => void send(text) }) return null } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx new file mode 100644 index 000000000000..f496af3e6c12 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom + +// A held queue's Resume is the composer's primary button exactly while nothing is typed or +// attached, no turn runs, and the queue controller offers it; Send otherwise, Stop while running. + +import { useRef, type ReactNode } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: { + children: ReactNode + variant?: string + size?: string + } & React.ButtonHTMLAttributes) => +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('./NativeChatSessionOptionPickers', () => ({ + NativeChatSessionOptionPickers: () => null +})) + +vi.mock('./NativeChatAutocompleteMenus', () => ({ + NativeChatMentionHint: () => null, + NativeChatPickerMenu: () => null +})) + +vi.mock('@/components/editor/useLocalImageSrc', () => ({ + useLocalImageSrc: () => undefined +})) + +import { NativeChatComposerField } from './NativeChatComposerField' +import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' + +afterEach(() => cleanup()) + +type FieldInput = { + /** The composer cannot send at all. */ + disabled?: boolean + draft?: string + imageAttachments?: { id: string; path: string }[] + isWorking?: boolean + queueResume?: { resume: () => void; resuming: boolean } +} + +const NO_IMAGES: { id: string; path: string }[] = [] + +function TestField({ + disabled = false, + draft = '', + imageAttachments = NO_IMAGES, + isWorking = false, + queueResume, + onSend +}: FieldInput & { onSend: () => void }): React.JSX.Element { + const imeEnterGesture = useImeEnterGestureOwnership() + const textareaRef = useRef(null) + return ( + + ) +} + +/** The primary button's accessible name: the last button in the actions row. */ +function primaryButton(input: FieldInput, onSend = vi.fn()): HTMLButtonElement { + render() + const buttons = screen.getAllByRole('button') + const last = buttons.at(-1) + if (!(last instanceof HTMLButtonElement)) { + throw new Error('expected the primary button') + } + return last +} + +describe('the composer primary button over a held queue', () => { + const held = () => ({ resume: vi.fn(), resuming: false }) + + it('is Resume on an empty composer with no turn running; one press resumes, never sends', () => { + const queueResume = held() + const onSend = vi.fn() + const button = primaryButton({ queueResume }, onSend) + expect(button.getAttribute('aria-label')).toBe('Resume') + expect(button.disabled).toBe(false) + fireEvent.click(button, { detail: 1 }) + expect(queueResume.resume).toHaveBeenCalledTimes(1) + expect(onSend).not.toHaveBeenCalled() + }) + + it.each([ + ['text is typed', { draft: 'hello' }, 'Send'], + ['an image is attached', { imageAttachments: [{ id: 'image-1', path: '/tmp/a.png' }] }, 'Send'], + ['a turn runs', { isWorking: true }, 'Stop the agent'] + ])('gives way when %s', (_case, input, label) => { + expect(primaryButton({ ...input, queueResume: held() }).getAttribute('aria-label')).toBe(label) + }) + + it('puts focus back in the composer after Resume', () => { + const button = primaryButton({ queueResume: held() }) + button.focus() + fireEvent.click(button, { detail: 1 }) + expect(document.activeElement).toBe(screen.getByRole('textbox')) + }) + + it('is disabled whenever Send would be: the composer cannot send', () => { + expect(primaryButton({ draft: 'hello', disabled: true }).disabled).toBe(true) + cleanup() + const resume = primaryButton({ disabled: true, queueResume: held() }) + expect(resume.getAttribute('aria-label')).toBe('Resume') + expect(resume.disabled).toBe(true) + }) + + it('is Send when nothing is held, and disabled while a Resume is in flight', () => { + expect(primaryButton({}).getAttribute('aria-label')).toBe('Send') + cleanup() + const resuming = primaryButton({ queueResume: { resume: vi.fn(), resuming: true } }) + expect(resuming.getAttribute('aria-label')).toBe('Resume') + expect(resuming.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts new file mode 100644 index 000000000000..e9f33e93a672 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatComposerPrimaryAction } from './native-chat-composer-primary-action' + +describe('the composer primary action', () => { + it.each([ + { isWorking: false, composerEmpty: true, queueHeld: true, action: 'resume' }, + { isWorking: false, composerEmpty: false, queueHeld: true, action: 'send' }, + { isWorking: false, composerEmpty: true, queueHeld: false, action: 'send' }, + { isWorking: false, composerEmpty: false, queueHeld: false, action: 'send' }, + { isWorking: true, composerEmpty: true, queueHeld: true, action: 'stop' }, + { isWorking: true, composerEmpty: false, queueHeld: false, action: 'stop' } + ])( + 'working $isWorking, empty $composerEmpty, held $queueHeld: $action', + ({ action, ...input }) => { + expect(nativeChatComposerPrimaryAction(input)).toBe(action) + } + ) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts new file mode 100644 index 000000000000..23f947ec6d2b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts @@ -0,0 +1,42 @@ +// The composer's one primary button. A held queue's Resume takes Send's place only while nothing +// is typed or attached, so a typed message always offers Send. + +export type NativeChatComposerPrimaryAction = 'stop' | 'resume' | 'send' + +/** Present while the host holds the queue and no turn runs. */ +export type NativeChatQueueResume = { resume: () => void; resuming: boolean } + +export function nativeChatComposerPrimaryAction(input: { + isWorking: boolean + composerEmpty: boolean + /** The host holds a card Resume would send. */ + queueHeld: boolean +}): NativeChatComposerPrimaryAction { + if (input.isWorking) { + return 'stop' + } + return input.composerEmpty && input.queueHeld ? 'resume' : 'send' +} + +/** The button as rendered: its action, whether it is disabled, and Resume's handler when it is + * Resume. Stop's and Send's own disabled state is `sendDisabled`. */ +export function nativeChatComposerPrimaryButton(input: { + isWorking: boolean + composerEmpty: boolean + queueResume: NativeChatQueueResume | undefined + /** The composer cannot send at all. */ + composerDisabled: boolean + sendDisabled: boolean +}): { action: NativeChatComposerPrimaryAction; disabled: boolean; resume?: () => void } { + const { queueResume } = input + const action = nativeChatComposerPrimaryAction({ + isWorking: input.isWorking, + composerEmpty: input.composerEmpty, + queueHeld: queueResume !== undefined + }) + if (action === 'resume' && queueResume) { + const disabled = input.composerDisabled || queueResume.resuming + return { action, disabled, resume: queueResume.resume } + } + return { action, disabled: input.sendDisabled } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index b06dd3a98428..954627c84a4a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -1,3 +1,4 @@ +import type { NativeChatQueueResume } from './native-chat-composer-primary-action' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { StructuredAgentContextUsage } from '../../../../shared/structured-agent-session-context-usage' import type { AgentSessionSlashCommand } from '../../../../shared/agent-session-wire' @@ -17,6 +18,14 @@ export type NativeChatOptionPickerRequest = { sequence: number } +/** A queue the host holds, with cards waiting. */ +export type NativeChatQueueHold = { + /** Every card shown, held or not. */ + count: number + /** Delete every card; false when one could not be (already reported). */ + clear: () => Promise +} + export type NativeChatStructuredComposerTransport = { conversationCommands?: readonly AgentSessionConversationCommand[] send: ( @@ -43,6 +52,11 @@ export type NativeChatStructuredComposerTransport = { sessionId: string /** Owning runtime for that report; null is the local runtime. */ runtimeEnvironmentId: string | null + /** Present while the queue is held: a message sent now first asks whether to clear its cards. */ + queueHold?: NativeChatQueueHold + /** Present while the host holds the queue and no turn runs: an empty composer's primary + * action becomes Resume, which releases it. */ + queueResume?: NativeChatQueueResume } export type NativeChatOptimisticSendOutcome = { diff --git a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx new file mode 100644 index 000000000000..faa8915f648d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx @@ -0,0 +1,394 @@ +import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support' +// @vitest-environment happy-dom + +// A message sent while the queue is held asks first, from Enter and from the button alike: +// Clear queue deletes every card and then sends, Send message sends and keeps them, and +// dismissing sends nothing and leaves the draft where it was. + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' +import type { + NativeChatQueueHold, + NativeChatStructuredComposerTransport +} from './native-chat-composer-types' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: { count?: number }) => + fallback.replace('{{count}}', String(options?.count ?? '')) +})) +// The button's own wiring: the primary action calls the same `send` Enter does. +vi.mock('./NativeChatComposerActions', () => ({ + NativeChatComposerActions: ({ onSend }: { onSend: () => void }) => ( + + ) +})) +vi.mock('./NativeChatAutocompleteMenus', () => ({ + NativeChatMentionHint: () => null, + NativeChatPickerMenu: () => null +})) +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + agentStatusByPaneKey: {}, + updateSettings: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchDraftAdopted: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: () => ({}) +})) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: vi.fn(), + sendNativeChatTypedCommand: vi.fn(), + sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: vi.fn() +})) +vi.mock('./claude-model-switch-confirmation', () => ({ + createClaudeModelSwitchConfirmationObserver: vi.fn() +})) +vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({ + ...(await importOriginal()), + getVerifiedNativeChatCommands: () => [] +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatPickerOpened: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) +vi.mock('../dictation/dictation-control-events', () => ({ + dispatchDictationControl: vi.fn() +})) + +import { NativeChatComposer } from './NativeChatComposer' + +const PASS_THROUGH = { handled: false, accepted: false, error: null } + +function transport(queueHold?: NativeChatQueueHold): NativeChatStructuredComposerTransport { + return { + send: vi.fn(() => true), + dispatchCommand: vi.fn(async () => PASS_THROUGH), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: 'session-test', + runtimeEnvironmentId: null, + ...(queueHold ? { queueHold } : {}) + } +} + +function heldQueue( + count: number, + cleared = true +): NativeChatQueueHold & { + clear: ReturnType +} { + return { count, clear: vi.fn(async () => cleared) } +} + +let paneCounter = 0 + +function renderComposer(structuredTransport: NativeChatStructuredComposerTransport) { + paneCounter += 1 + render( + + ) + return screen.getByRole('textbox') +} + +function pressEnter(input: HTMLElement): void { + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, isComposing: false }) +} + +const BODY = + 'You are about to send a message. Do you want to clear the 2 messages previously queued?' + +beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + git: { discoverCommitMessageModels: vi.fn().mockResolvedValue({ success: false }) }, + pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) }, + ui: { onFileDrop: () => vi.fn() } + } + }) +}) + +afterEach(() => cleanup()) + +describe('sending while the queue is held', () => { + it('Enter and the send button both ask first, and nothing is sent until the person chooses', async () => { + for (const submit of [pressEnter, () => fireEvent.click(screen.getByTestId('composer-send'))]) { + const structured = transport(heldQueue(2)) + const input = renderComposer(structured) + changePrompt(input, 'a new instruction') + await act(async () => submit(input)) + const dialog = await screen.findByRole('dialog') + expect(dialog.textContent).toContain('Send message?') + expect(dialog.textContent).toContain(BODY) + expect(screen.getByRole('button', { name: 'Clear queue' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Send message' })).toBeTruthy() + expect(structured.dispatchCommand).not.toHaveBeenCalled() + expect(structured.send).not.toHaveBeenCalled() + cleanup() + } + }) + + it('Send message sends the draft and keeps the cards', async () => { + const hold = heldQueue(2) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'a new instruction') + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Send message' })) + ) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('a new instruction', [])) + expect(hold.clear).not.toHaveBeenCalled() + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('Clear queue deletes every card first, then sends', async () => { + const deleted = Promise.withResolvers() + const hold = heldQueue(2) + hold.clear.mockReturnValue(deleted.promise) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'start over') + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Clear queue' })) + ) + expect(hold.clear).toHaveBeenCalledTimes(1) + // Nothing goes out while the deletes are on their way. + await act(async () => {}) + expect(structured.dispatchCommand).not.toHaveBeenCalled() + expect(structured.send).not.toHaveBeenCalled() + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + }) + + it('a Clear queue whose delete fails sends nothing and keeps the draft', async () => { + const hold = heldQueue(2, false) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'start over') + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Clear queue' })) + ) + await waitFor(() => expect(hold.clear).toHaveBeenCalledTimes(1)) + await act(async () => {}) + expect(structured.dispatchCommand).not.toHaveBeenCalled() + expect(structured.send).not.toHaveBeenCalled() + expect(promptValue(input)).toBe('start over') + }) + + it('dismissing sends nothing, keeps the draft and returns focus to the composer', async () => { + const hold = heldQueue(2) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'not yet') + await act(async () => pressEnter(input)) + const dialog = await screen.findByRole('dialog') + await act(async () => fireEvent.keyDown(dialog, { key: 'Escape' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(structured.send).not.toHaveBeenCalled() + expect(hold.clear).not.toHaveBeenCalled() + expect(promptValue(input)).toBe('not yet') + await waitFor(() => expect(input.contains(document.activeElement)).toBe(true)) + }) + + describe('a choice is taken once', () => { + /** The primitive's exit animation, which keeps the closing dialog mounted and clickable. */ + function withExitAnimation(): () => void { + const style = document.createElement('style') + style.textContent = + '[role="dialog"][data-state="closed"] { animation-name: exit; animation-duration: 200ms; }' + document.head.appendChild(style) + return () => style.remove() + } + + it('Send message pressed again while the dialog closes sends nothing more', async () => { + const removeAnimation = withExitAnimation() + try { + const structured = transport(heldQueue(1)) + const input = renderComposer(structured) + changePrompt(input, 'once only') + await act(async () => pressEnter(input)) + const sendButton = await screen.findByRole('button', { name: 'Send message' }) + await act(async () => fireEvent.click(sendButton)) + expect(document.body.contains(sendButton)).toBe(true) + await act(async () => fireEvent.click(sendButton)) + await act(async () => {}) + expect(structured.send).toHaveBeenCalledTimes(1) + } finally { + removeAnimation() + } + }) + + it('a double-click, or a held Enter, before any re-render sends once', async () => { + const structured = transport(heldQueue(1)) + const input = renderComposer(structured) + changePrompt(input, 'once only') + await act(async () => pressEnter(input)) + const sendButton = await screen.findByRole('button', { name: 'Send message' }) + await act(async () => { + sendButton.click() + sendButton.click() + }) + await act(async () => {}) + expect(structured.send).toHaveBeenCalledTimes(1) + }) + + it('Clear queue pressed twice clears once and sends once', async () => { + const removeAnimation = withExitAnimation() + try { + const hold = heldQueue(2) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'start over') + await act(async () => pressEnter(input)) + const clearButton = await screen.findByRole('button', { name: 'Clear queue' }) + await act(async () => { + clearButton.click() + clearButton.click() + }) + await act(async () => fireEvent.click(clearButton)) + await waitFor(() => expect(structured.send).toHaveBeenCalledTimes(1)) + await act(async () => {}) + expect(hold.clear).toHaveBeenCalledTimes(1) + expect(structured.send).toHaveBeenCalledTimes(1) + } finally { + removeAnimation() + } + }) + }) + + describe("while Clear queue's deletes run", () => { + async function clearingQueue(text: string) { + const deleted = Promise.withResolvers() + const hold = heldQueue(2) + hold.clear.mockReturnValue(deleted.promise) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, text) + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Clear queue' })) + ) + return { deleted, structured, input } + } + + it('sending again sends nothing: the message goes out once, after the deletes', async () => { + const { deleted, structured, input } = await clearingQueue('start over') + // The draft still holds the message. + expect(promptValue(input)).toBe('start over') + await act(async () => pressEnter(input)) + await act(async () => fireEvent.click(screen.getByTestId('composer-send'))) + await act(async () => {}) + expect(screen.queryByRole('dialog')).toBeNull() + expect(structured.send).not.toHaveBeenCalled() + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + await act(async () => {}) + expect(structured.send).toHaveBeenCalledTimes(1) + }) + + it('only the sent message leaves the composer; text typed meanwhile stays', async () => { + const { deleted, structured, input } = await clearingQueue('start over') + changePrompt(input, 'start over, and also check the tests') + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + await act(async () => {}) + expect(promptValue(input)).toBe(', and also check the tests') + }) + + it('an unchanged draft is cleared once the message is accepted', async () => { + const { deleted, structured, input } = await clearingQueue('start over') + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + await waitFor(() => expect(promptValue(input)).toBe('')) + }) + }) + + it('does not ask when the queue is not held, nor for a command the host runs itself', async () => { + const free = transport() + const input = renderComposer(free) + changePrompt(input, 'plain send') + await act(async () => pressEnter(input)) + await waitFor(() => expect(free.send).toHaveBeenCalledWith('plain send', [])) + expect(screen.queryByRole('dialog')).toBeNull() + cleanup() + const held = transport(heldQueue(2)) + const commandInput = renderComposer(held) + changePrompt(commandInput, '/compact') + await act(async () => pressEnter(commandInput)) + await waitFor(() => expect(held.dispatchCommand).toHaveBeenCalledWith('/compact')) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes when the pause lifts under it: nothing is sent, the draft stays, and the next Enter sends', async () => { + const held = transport(heldQueue(2)) + const composer = (structuredTransport: NativeChatStructuredComposerTransport) => ( + + ) + const view = render(composer(held)) + const input = screen.getByRole('textbox') + changePrompt(input, 'a new instruction') + await act(async () => pressEnter(input)) + await screen.findByRole('dialog') + // Orca's mail or another client's Resume lifts the pause: the host stops publishing the hold. + const { queueHold: _lifted, ...lifted } = held + view.rerender(composer(lifted)) + await act(async () => {}) + expect(screen.queryByRole('dialog')).toBeNull() + expect(held.send).not.toHaveBeenCalled() + expect(promptValue(input)).toBe('a new instruction') + // A later pause does not bring the old question back. + view.rerender(composer({ ...lifted, queueHold: heldQueue(1) })) + await act(async () => {}) + expect(screen.queryByRole('dialog')).toBeNull() + view.rerender(composer(lifted)) + await act(async () => pressEnter(input)) + await waitFor(() => expect(held.send).toHaveBeenCalledWith('a new instruction', [])) + expect(held.send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts index 423e114647f7..c677928e0358 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts @@ -4,7 +4,7 @@ import type { NativeChatAfterStopSend } from './native-chat-composer-target' type StopController = Pick< ReturnType, - 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' | 'sendsQueue' + 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' | 'sendsQueue' | 'queueSendsNext' > /** @@ -19,8 +19,9 @@ export function nativeChatStructuredStopControls( ): { stopping: boolean composer: { + isWorking: boolean isStopping: boolean - onStop: () => void + onStop: (() => void) | undefined steerQueued: (() => boolean) | undefined afterStop: NativeChatAfterStopSend | undefined } @@ -35,8 +36,10 @@ export function nativeChatStructuredStopControls( return { stopping, composer: { + // Stop is offered whenever the chat looks busy, live only once a turn can be stopped. + isWorking: controller.canStop || controller.queueSendsNext, isStopping: stopInFlight, - onStop: () => void (stopInFlight || controller.stop()), + onStop: controller.canStop ? () => void (stopInFlight || controller.stop()) : undefined, steerQueued: stopping ? undefined : controller.queuedMessages.steerNewest, afterStop: stopping ? (controller.sendsQueue ? 'queue' : 'send') : undefined } diff --git a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx index 1596863f2a39..a93b7b3671c2 100644 --- a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx @@ -163,7 +163,7 @@ it('reveals a structured command at the press, before the host answers it', asyn }) readerScrollAway(hook.result.current) hook.result.current.scrollToEnd.mockClear() - act(() => hook.result.current.send('/compact')) + act(() => void hook.result.current.send('/compact')) expect(hook.result.current.scrollToEnd).toHaveBeenCalled() // The answer arriving later leaves a reader who has since scrolled away. readerScrollAway(hook.result.current) @@ -411,7 +411,9 @@ it('reveals a structured prompt answer at the press, before the host accepts it' steer: async () => {}, remove: async () => {}, edit: async () => {}, - steerNewest: () => false + steerNewest: () => false, + queueResume: undefined, + queueHold: undefined } }, vi.fn() 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 986b8eb0981b..28df16cb67b0 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 @@ -1,5 +1,5 @@ // Card projection policy: queue order, derived hold labels (the wire carries -// none), and the presentation-only suppression of a card whose submission +// holds, not labels), and the presentation-only suppression of a card whose submission // already arrived — a queued draft is otherwise never a transcript bubble. import { describe, expect, it } from 'vitest' @@ -9,7 +9,9 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { newestSteerableQueuedMessageCard, outboxOutsideQueuedCards, - projectQueuedMessageCards + projectQueuedMessageCards, + queuedMessageCardSteers, + queuedMessagesQueuePause } from './structured-agent-session-queued-cards' function draft( @@ -159,6 +161,37 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) + it('a paused queue holds every waiting card, in order: an answer does not drain it', () => { + const cards = projectQueuedMessageCards([draft('held', 1), draft('typed-after', 2)], [], { + hasPendingPrompt: true, + queuePaused: true + }) + expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'queue-paused']) + // Still Steer: the header row, not the card, says it waits. + expect(cards.map((card) => queuedMessageCardSteers(card))).toEqual([true, true]) + }) + + it("the header names the queue's pause while it holds a card, and none over cards Resume would not send", () => { + const stopped = { reason: 'stopped' } as const + const project = (messages: AgentSessionQueuedMessage[], queuePaused = true) => + projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused }) + expect(queuedMessagesQueuePause(project([draft('held', 1)]), stopped)).toEqual(stopped) + expect(queuedMessagesQueuePause(project([draft('held', 1)]), { reason: 'cleared' })).toEqual({ + reason: 'cleared' + }) + const unsendable = [ + draft('returned', 1, { state: 'returned', returnedReason: null }), + draft('behind', 2), + draft('failed', 3, { paused: true, pausedReason: 'send_failed' }), + draft('kept', 4, { paused: true, pausedReason: 'kept' }) + ] + expect(queuedMessagesQueuePause(project(unsendable), stopped)).toBeNull() + // No published pause (an idle chat after a restart): plain cards, no header. + const unpaused = project([draft('waiting', 1)], false) + expect(unpaused.map((card) => card.hold)).toEqual(['turn']) + expect(queuedMessagesQueuePause(unpaused, null)).toBeNull() + }) + // 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( 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 f49f3e32c1d4..4720d9eefbc3 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 type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' import { readAgentMessageSource, type AgentMessageSource @@ -99,6 +102,21 @@ export function projectQueuedMessageCards( }) } +/** The pause the header row names, while it holds a card. A pause over cards Resume would not + * send (returned, held on their own, or behind a returned one) offers nothing to press. */ +export function queuedMessagesQueuePause( + cards: readonly QueuedMessageCard[], + queuePause: AgentSessionQueuePause | null +): AgentSessionQueuePause | null { + return cards.some((card) => card.hold === 'queue-paused') ? queuePause : null +} + +/** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own or + * returned is not waiting on the turn, so its action is plainly Send. */ +export function queuedMessageCardSteers(card: QueuedMessageCard): boolean { + return card.hold !== 'paused' && card.hold !== 'returned' +} + /** The card Cmd/Ctrl+Enter steers: the newest one; every shown card takes Send-now. */ export function newestSteerableQueuedMessageCard( cards: readonly QueuedMessageCard[] diff --git a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts new file mode 100644 index 000000000000..213ad5794933 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts @@ -0,0 +1,143 @@ +// A message sent while the queue is held asks first whether to clear the cards, which would +// otherwise wait behind it. A host command is not a message and sends as it is. + +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { + isNativeChatStructuredHostCommand, + useNativeChatStructuredComposerSend, + type NativeChatStructuredComposerSend, + type UseNativeChatStructuredComposerSendArgs +} from './use-native-chat-structured-composer-send' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { readNativeChatComposerDraft } from './native-chat-composer-draft-store' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' +import type { NativeChatQueueHold } from './native-chat-composer-types' +import type { NativeChatQueueResume } from './native-chat-composer-primary-action' + +type StructuredComposerSend = ( + text: string, + attachments?: readonly NativeChatComposerImageAttachment[] +) => void + +/** The confirmation as the dialog shows it; kept after it closes so the closing frame still reads. */ +export type NativeChatQueueSendConfirm = { + open: boolean + /** The cards waiting when it opened. */ + count: number + /** Delete every card, then send; a failed delete sends nothing. */ + clearQueue: () => void + /** Send and keep the cards: the message's turn lifts the pause, so they follow it. */ + sendMessage: () => void + /** Send nothing; the draft and its attachments stay in the composer. */ + dismiss: () => void +} + +type PendingSend = { + text: string + attachments: readonly NativeChatComposerImageAttachment[] | undefined + /** The hold it was asked under: the question stands only while that hold does. */ + hold: NativeChatQueueHold + /** The draft as it was when the message was asked for. */ + sentFrom: NativeChatComposerDraft +} + +export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructuredComposerSendArgs): { + send: StructuredComposerSend + /** The message field's queue controls: the composer's Resume and the "Send message?" choice. */ + fieldProps: { + queueResume: NativeChatQueueResume | undefined + queueSendConfirm: NativeChatQueueSendConfirm | null + } +} { + const sendNow = useNativeChatStructuredComposerSend(args) + const { agent, structuredTransport } = args + const queueHold = structuredTransport?.queueHold + const [pending, setPending] = useState(null) + const [open, setOpen] = useState(false) + // The send a choice may still take, taken once: the closing dialog stays clickable for its exit + // animation, and a double-click or a held Enter lands twice before any re-render. + const untakenRef = useRef(null) + // A pause lifted under the open dialog (Orca's mail, another client's Resume) voids the choice: + // nothing is sent, and the draft stays for the next Enter, which then sends as usual. + const take = useCallback((): PendingSend | null => { + const taken = untakenRef.current + untakenRef.current = null + setOpen(false) + return taken?.hold === queueHold ? taken : null + }, [queueHold]) + // Clear queue sends once its deletes settle, through the send of that render. + const sendNowRef = useRef(sendNow) + useLayoutEffect(() => { + sendNowRef.current = sendNow + }, [sendNow]) + // From a Clear queue choice until its message has gone out: the draft still holds that message, + // and sending it again meanwhile would send it twice. + const clearingRef = useRef(false) + + const send = useCallback( + (text, attachments) => { + if (clearingRef.current) { + return + } + if ( + queueHold && + structuredTransport && + !isNativeChatStructuredHostCommand(text, agent, structuredTransport) + ) { + const asked = { + text, + attachments, + hold: queueHold, + sentFrom: readNativeChatComposerDraft(args.draftScopeKey) + } + untakenRef.current = asked + setPending(asked) + setOpen(true) + return + } + void sendNow(text, attachments) + }, + [agent, args.draftScopeKey, queueHold, sendNow, structuredTransport] + ) + + const sendMessage = useCallback(() => { + const taken = take() + if (taken) { + void sendNowRef.current(taken.text, taken.attachments, taken.sentFrom) + } + }, [take]) + const clearQueue = useCallback(() => { + const taken = take() + if (!taken) { + return + } + clearingRef.current = true + void (async () => { + try { + if (await taken.hold.clear()) { + // Text typed during the deletes stays: the message was taken from what came before. + await sendNowRef.current(taken.text, taken.attachments, taken.sentFrom) + } + } finally { + clearingRef.current = false + } + })() + }, [take]) + const dismiss = useCallback(() => { + take() + }, [take]) + + const confirm = pending + ? { + open: open && pending.hold === queueHold, + count: pending.hold.count, + clearQueue, + sendMessage, + dismiss + } + : null + return { + send, + fieldProps: { queueResume: structuredTransport?.queueResume, queueSendConfirm: confirm } + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index 385df5bc19c3..5ba38731c22d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -17,6 +17,7 @@ import { updateNativeChatComposerDraft } from './native-chat-composer-draft-store' import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' export type UseNativeChatStructuredComposerSendArgs = { agent: AgentType @@ -30,6 +31,26 @@ export type UseNativeChatStructuredComposerSendArgs = { setCaret: (caret: number) => void } +/** A command the host runs itself (and `/goal` where the host sets goals), never a message. */ +export function isNativeChatStructuredHostCommand( + text: string, + agent: AgentType, + transport: NativeChatStructuredComposerTransport +): boolean { + return ( + isStructuredAgentSessionComposerCommand(text, agent) || + (transport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text)) + ) +} + +/** A structured send. `sentFrom`: the draft the message was taken from, for a send that goes out + * later than it was asked for; only what it held then leaves the composer. */ +export type NativeChatStructuredComposerSend = ( + text: string, + attachments?: readonly NativeChatComposerImageAttachment[], + sentFrom?: NativeChatComposerDraft +) => Promise + /** Send through the structured journal transport, clearing the composer only * once the transport accepts (the PTY path has its own sibling hook). */ export function useNativeChatStructuredComposerSend({ @@ -42,18 +63,13 @@ export function useNativeChatStructuredComposerSend({ setHistory, setDraft, setCaret -}: UseNativeChatStructuredComposerSendArgs): ( - text: string, - attachments?: readonly NativeChatComposerImageAttachment[] -) => void { - return useCallback( - (text: string, attachments = imageAttachments): void => { +}: UseNativeChatStructuredComposerSendArgs): NativeChatStructuredComposerSend { + return useCallback( + async (text, attachments = imageAttachments, sentFrom): Promise => { if (!structuredTransport) { return } - const hostCommand = - isStructuredAgentSessionComposerCommand(text, agent) || - (structuredTransport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text)) + const hostCommand = isNativeChatStructuredHostCommand(text, agent, structuredTransport) // A command picked while images await re-attaching would send them without a file. const attachAgain = nativeChatAttachImagesAgainReason(attachments) if (attachAgain) { @@ -68,8 +84,8 @@ export function useNativeChatStructuredComposerSend({ if (hostCommand && structuredAgentSessionCommandChangesConversation(text)) { structuredTransport.onSubmitted?.() } - const submitted = readNativeChatComposerDraft(draftScopeKey) - void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) + const submitted = sentFrom ?? readNativeChatComposerDraft(draftScopeKey) + await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error, revealsTranscript }) => { structuredTransport.onError(error) if (!accepted) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx new file mode 100644 index 000000000000..ab6d7fd7263c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment happy-dom + +// What a message sent over a held queue asks about: offered exactly while the header row shows, +// counting every card shown, and Clear queue deletes each of them through the card's own Delete. + +import { act, render, renderHook, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setRendererUiLanguage } from '@/i18n/i18n' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' +import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { NativeChatQueueSendConfirmDialog } from './NativeChatQueueSendConfirmDialog' + +const STOPPED = { reason: 'stopped' } as const + +function card( + messageId: string, + position: number, + fields: Partial = {} +): AgentSessionQueuedMessage { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + return { messageId, position, body, state: 'waiting', ...fields } +} + +type DeleteAnswer = { deleted: true; messageId: string } | null + +function renderController( + queuedMessages: AgentSessionQueuedMessage[], + options: { + queuePause?: AgentSessionQueuePause | null + enabled?: boolean + isWorking?: boolean + answer?: (messageId: string) => DeleteAnswer + } = {} +) { + const answer = options.answer ?? ((messageId) => ({ deleted: true, messageId })) + const mutate = vi.fn( + async (_method: string, _fingerprint: string, fields: Record) => + answer(String(fields.messageId)) + ) + const view = renderHook(() => + useStructuredAgentSessionQueuedMessages({ + enabled: options.enabled ?? true, + queuedMessages, + queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, + submissions: [], + hasPendingPrompt: false, + isWorking: options.isWorking === true, + composerScopeKey: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape. + mutate: mutate as unknown as StructuredAgentSessionMutate + }) + ) + return { ...view, mutate } +} + +afterEach(async () => { + vi.clearAllMocks() + await setRendererUiLanguage('en') +}) + +describe('the held queue a new message asks about', () => { + it('is offered while a pause holds a card, counting every card shown, held or not', () => { + const { result } = renderController([ + card('held', 1), + card('typed-after', 2), + card('failed', 3, { paused: true, pausedReason: 'send_failed' }) + ]) + expect(result.current.pause).toEqual(STOPPED) + expect(result.current.queueHold?.count).toBe(3) + }) + + it('is not offered when no pause holds a card, with no cards, or without the queue', () => { + const unheld = [card('waiting', 1)] + expect(renderController(unheld, { queuePause: null }).result.current.queueHold).toBeUndefined() + const ownHeld = [card('failed', 1, { paused: true })] + expect(renderController(ownHeld).result.current.queueHold).toBeUndefined() + expect(renderController([]).result.current.queueHold).toBeUndefined() + const held = [card('held', 1)] + expect(renderController(held, { enabled: false }).result.current.queueHold).toBeUndefined() + }) + + it('is not offered while a turn runs, though the paused row shows as the Stop winds down', () => { + const held = [card('held', 1)] + const { result } = renderController(held, { isWorking: true }) + expect(result.current.pause).toEqual(STOPPED) + expect(result.current.queueHold).toBeUndefined() + // Enter queues behind the held cards as usual; once nothing runs it asks again. + expect(renderController(held).result.current.queueHold?.count).toBe(1) + }) + + it('Clear queue deletes every card shown, and answers true once all are gone', async () => { + const { result, mutate } = renderController([card('held', 1), card('typed-after', 2)]) + let cleared: boolean | undefined + await act(async () => { + cleared = await result.current.queueHold?.clear() + }) + expect(cleared).toBe(true) + expect(mutate.mock.calls.map(([method, , fields]) => [method, fields.messageId])).toEqual([ + ['agentSession.queuedMessageDelete', 'held'], + ['agentSession.queuedMessageDelete', 'typed-after'] + ]) + }) + + it('a delete that fails makes Clear queue answer false', async () => { + const { result } = renderController([card('held', 1), card('typed-after', 2)], { + answer: (messageId) => (messageId === 'held' ? null : { deleted: true, messageId }) + }) + let cleared: boolean | undefined + await act(async () => { + cleared = await result.current.queueHold?.clear() + }) + expect(cleared).toBe(false) + }) +}) + +describe('the confirmation copy', () => { + it('counts the queued messages in the plural the language uses', () => { + const confirm = (count: number) => ({ + open: true, + count, + clearQueue: vi.fn(), + sendMessage: vi.fn(), + dismiss: vi.fn() + }) + const view = render( + + ) + expect(screen.getByRole('dialog').textContent).toContain( + 'Do you want to clear the 1 message previously queued?' + ) + view.rerender() + expect(screen.getByRole('dialog').textContent).toContain( + 'Do you want to clear the 3 messages previously queued?' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 6fb17f53122d..bfe2599e6572 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -1,11 +1,17 @@ // @vitest-environment happy-dom -// Resume lifts the queue's pause through its own RPC, over the same fenced write every card action -// uses: a refusal or a failure is one toast, and the Resume button is the way to try again. +// Resume releases a held queue through its own RPC, over the same fenced write every card action +// uses. The header row offers it whenever the host holds a card it would send, the composer only +// while no turn runs too; one press at a time, a refusal or a failure is one toast, and Resume +// stays the way to try again. import { useCallback, useRef } from 'react' import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' type ResumeParams = { envelope: { clientOperationId: string } } @@ -25,35 +31,63 @@ import { useStructuredNativeChatSubmitReveal } from './use-structured-native-cha import { useNativeChatMessageListHandle } from './use-native-chat-reveal-latest' import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll' -function resumedResult(resumed = true) { - return { - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed } - } +const RESUMED = { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { resumed: true } } -function useQueue(sessionId = 'session-1', fence: number | null = 1) { - const { mutate } = useStructuredAgentSessionMutate({ - sessionId, - target: { kind: 'local' }, - stateRef: { current: { fence } } - }) - return useStructuredAgentSessionQueuedMessages({ - enabled: true, - queuedMessages: [], - queuePause: { reason: 'stopped' }, - submissions: [], - hasPendingPrompt: false, - composerScopeKey: undefined, - mutate - }) +function card( + messageId: string, + fields: Partial = {} +): AgentSessionQueuedMessage { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + return { messageId, position: 1, body, state: 'waiting', ...fields } +} + +type ControllerInput = { + enabled?: boolean + queuedMessages?: AgentSessionQueuedMessage[] + queuePause?: AgentSessionQueuePause | null + isWorking?: boolean + hasPendingPrompt?: boolean } -function renderController() { - return renderHook(() => useQueue()) +function renderController(initialProps: ControllerInput = {}) { + const stateRef = { current: { fence: 1 } } + return renderHook( + (input: ControllerInput) => { + const { mutate } = useStructuredAgentSessionMutate({ + sessionId: 'session-1', + target: { kind: 'local' }, + stateRef + }) + return useStructuredAgentSessionQueuedMessages({ + enabled: input.enabled ?? true, + queuedMessages: input.queuedMessages ?? [card('held')], + queuePause: input.queuePause === undefined ? { reason: 'stopped' } : input.queuePause, + submissions: [], + hasPendingPrompt: input.hasPendingPrompt ?? false, + isWorking: input.isWorking ?? false, + composerScopeKey: undefined, + mutate + }) + }, + { initialProps } + ) +} + +/** A press of the composer's Resume, which the controller offers only over a held queue. */ +function resume(result: { + current: { queueResume: { resume: () => Promise } | undefined } +}) { + const offered = result.current.queueResume + if (!offered) { + throw new Error('expected Resume to be offered') + } + return offered.resume() } afterEach(() => { @@ -61,18 +95,121 @@ afterEach(() => { vi.resetAllMocks() }) -describe('Resume on a paused queue', () => { - it('calls queuedMessagesResume with only an envelope, and says nothing when it lands', async () => { - mocks.call.mockResolvedValue({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed: true } +describe('whether Resume is offered', () => { + it.each(['stopped', 'cleared', 'some-newer-reason'])( + "over a card the host holds ('%s') with no turn running", + (reason) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. + const queuePause = { reason } as AgentSessionQueuePause + const { result } = renderController({ queuePause }) + expect(result.current.queueResume).toBeDefined() + } + ) + + it('not without the queue capability: an older host, or no fence yet while connecting', () => { + expect(renderController({ enabled: false }).result.current.queueResume).toBeUndefined() + }) + + it('not while a turn runs, nor when nothing is held', () => { + expect(renderController({ isWorking: true }).result.current.queueResume).toBeUndefined() + expect(renderController({ queuePause: null }).result.current.queueResume).toBeUndefined() + expect(renderController({ queuedMessages: [] }).result.current.queueResume).toBeUndefined() + }) + + it('not while a prompt waits, which holds the queue too: the composer shows beside one this build cannot answer', () => { + const { result } = renderController({ hasPendingPrompt: true }) + expect(result.current.pause).toEqual({ reason: 'stopped' }) + expect(result.current.queueResume).toBeUndefined() + expect(result.current.queueHold).toBeUndefined() + }) + + it('an idle chat with cards and no published pause (as after a restart): no row, Resume or "Send message?"', () => { + const { result } = renderController({ queuePause: null }) + expect(result.current.cards.map((entry) => entry.hold)).toEqual(['turn']) + expect(result.current.pause).toBeNull() + expect(result.current.queueResume).toBeUndefined() + expect(result.current.queueHold).toBeUndefined() + }) + + it('not over cards Resume would not send: held on their own, returned, or behind one', () => { + const queuedMessages = [ + card('failed', { paused: true, pausedReason: 'send_failed' }), + card('returned', { position: 2, state: 'returned' }), + card('behind', { position: 3 }) + ] + expect(renderController({ queuedMessages }).result.current.queueResume).toBeUndefined() + }) +}) + +describe('while a turn runs over held cards', () => { + it('the header row still names the pause and offers Resume; the composer does not', () => { + // `isWorking` counts the queue's coming send, which the host names. + const { result } = renderController({ isWorking: true }) + expect(result.current.pause).toEqual({ reason: 'stopped' }) + expect(result.current.queueResume).toBeUndefined() + }) +}) + +describe("the header row's pause", () => { + it.each(['stopped', 'cleared', 'some-newer-reason'])( + "names the reason the host holds the queue for ('%s')", + (reason) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. + const queuePause = { reason } as AgentSessionQueuePause + const { result } = renderController({ queuePause }) + expect(result.current.pause).toEqual({ reason }) + } + ) + + it('is absent over cards held only on their own or returned, and with no published pause', () => { + const queuedMessages = [ + card('failed', { paused: true, pausedReason: 'send_failed' }), + card('returned', { position: 2, state: 'returned' }) + ] + expect(renderController({ queuedMessages }).result.current.pause).toBeNull() + expect(renderController({ queuePause: null }).result.current.pause).toBeNull() + }) + + it("shares one Resume with the composer's: a press of either while one is in flight sends nothing", async () => { + const answer = Promise.withResolvers() + mocks.call.mockReturnValueOnce(answer.promise) + const { result } = renderController() + let pending: Promise = Promise.resolve(false) + act(() => { + pending = result.current.resume() }) + expect(result.current.resuming).toBe(true) + expect(result.current.queueResume?.resuming).toBe(true) + await act(() => resume(result)) + expect(mocks.call).toHaveBeenCalledTimes(1) + await act(async () => { + answer.resolve(RESUMED) + await pending + }) + expect(result.current.resuming).toBe(false) + }) +}) + +describe('Clear queue before a new message', () => { + it('one failed press is one toast, however many cards it held', async () => { + mocks.call.mockRejectedValue(new Error('socket closed')) + const { result } = renderController({ + queuedMessages: [card('a'), card('b', { position: 2 }), card('c', { position: 3 })] + }) + let cleared: boolean | undefined + await act(async () => { + cleared = await result.current.queueHold?.clear() + }) + expect(cleared).toBe(false) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + }) +}) + +describe('Resume on a held queue', () => { + it('calls queuedMessagesResume with only an envelope, and says nothing when it lands', async () => { + mocks.call.mockResolvedValue(RESUMED) const { result } = renderController() - expect(result.current.pause).toEqual({ reason: 'stopped' }) - await act(async () => expect(await result.current.resume()).toBe(true)) + await act(() => resume(result)) expect(mocks.call).toHaveBeenCalledTimes(1) const [, method, params] = mocks.call.mock.calls[0] ?? [] expect(method).toBe('agentSession.queuedMessagesResume') @@ -80,6 +217,24 @@ describe('Resume on a paused queue', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) + it('a second press while one is in flight sends nothing', async () => { + const answer = Promise.withResolvers() + mocks.call.mockReturnValueOnce(answer.promise) + const { result } = renderController() + let pending: Promise = Promise.resolve(false) + act(() => { + pending = resume(result) + }) + expect(result.current.queueResume?.resuming).toBe(true) + await act(() => resume(result)) + expect(mocks.call).toHaveBeenCalledTimes(1) + await act(async () => { + answer.resolve(RESUMED) + await pending + }) + expect(result.current.queueResume?.resuming).toBe(false) + }) + it('a refused or failed Resume is one toast', async () => { mocks.call.mockResolvedValueOnce({ ok: false, @@ -87,60 +242,53 @@ describe('Resume on a paused queue', () => { }) mocks.call.mockRejectedValueOnce(new Error('socket closed')) const { result } = renderController() - await act(async () => expect(await result.current.resume()).toBe(false)) + await act(() => resume(result)) expect(mocks.toastError).toHaveBeenCalledTimes(1) - await act(async () => expect(await result.current.resume()).toBe(false)) + await act(() => resume(result)) expect(mocks.toastError).toHaveBeenCalledTimes(2) }) it('every press is its own operation: a failed Resume never pins the next one to its id', async () => { // Resume names no target, so a replayed id would answer `{ resumed: false }` or repeat the - // same refusal instead of lifting whatever pause holds now. + // same refusal instead of lifting whatever holds the queue now. mocks.call.mockRejectedValueOnce(new Error('socket closed')) mocks.call.mockResolvedValueOnce({ ok: false, refusal: { code: 'agent_session_operation_unknown', message: 'Unknown operation.' } }) - mocks.call.mockResolvedValueOnce({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed: true } - }) + mocks.call.mockResolvedValueOnce(RESUMED) const { result } = renderController() for (let press = 0; press < 3; press += 1) { - await act(() => result.current.resume()) + await act(() => resume(result)) } const ids = mocks.call.mock.calls.map(([, , params]) => params.envelope.clientOperationId) expect(ids).toHaveLength(3) expect(new Set(ids).size).toBe(3) }) +}) - it('reports a Resume in flight until it settles', async () => { - const answer = Promise.withResolvers() - mocks.call.mockReturnValueOnce(answer.promise) - const { result } = renderController() - let pending = Promise.resolve(false) - act(() => { - pending = result.current.resume() - }) - expect(result.current.resuming).toBe(true) - await act(async () => expect(await result.current.resume()).toBe(false)) - expect(mocks.call).toHaveBeenCalledTimes(1) - await act(async () => { - answer.resolve({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed: true } - }) - expect(await pending).toBe(true) - }) - expect(result.current.resuming).toBe(false) +function resumedResult(resumed = true) { + return { ...RESUMED, value: { resumed } } +} + +/** The queue controller as a pane builds it, over the real mutation. */ +function useQueue(sessionId = 'session-1', fence: number | null = 1) { + const { mutate } = useStructuredAgentSessionMutate({ + sessionId, + target: { kind: 'local' }, + stateRef: { current: { fence } } }) -}) + return useStructuredAgentSessionQueuedMessages({ + enabled: true, + queuedMessages: [card('held')], + queuePause: { reason: 'stopped' }, + submissions: [], + hasPendingPrompt: false, + isWorking: false, + composerScopeKey: undefined, + mutate + }) +} function renderRevealingController(sessionId = 'session-1', fence: number | null = 1) { return renderHook( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index 2c5257725a58..5d1f642071fc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -55,6 +55,7 @@ function createHarness( queuePause: null, submissions: [], hasPendingPrompt: false, + isWorking: false, composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that. mutate: mutate as StructuredAgentSessionMutate 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 5b70e068fe05..dfb5a211c5c8 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 @@ -1,8 +1,9 @@ // Host-held drafts as this pane acts on them: the card list, Send-now (Steer), -// Delete, Edit, and the Cmd/Ctrl+Enter steer chord. Everything durable lives on -// the host, and no draft text ever travels back over the wire: Edit copies the -// text the card already shows into the composer, locally, before deleting the -// draft, so no RPC outcome can lose it. +// Delete, Edit, the Cmd/Ctrl+Enter steer chord, Resume of a held queue, and +// clearing it before a new message. +// Everything durable lives on the host, and no draft text ever travels back over +// the wire: Edit copies the text the card already shows into the composer, +// locally, before deleting the draft, so no RPC outcome can lose it. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' @@ -20,19 +21,22 @@ import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draf import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + queuedMessagesQueuePause, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { NativeChatQueueHold } from './native-chat-composer-types' export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] /** The host queues sends. Without it a card can still show — a message the host kept unsent — * but queueing settings and the steer chord would do nothing. */ queueCapable: boolean - /** Why the whole queue sends nothing on its own; null when it drains. Shown only with cards. - * A string reason: a newer host may name one this build does not know. */ + /** Why the queue holds cards Resume would send: the header row above them. A string reason: a + * newer host may name one this build does not know. */ pause: { reason: string } | null - /** Whether the host lifted the pause; a failure is a toast, and Resume is the retry. */ + /** Whether the host lifted the pause; a failure is a toast, and Resume is the retry. Shared by + * the header row and the composer. */ resume: () => Promise /** A Resume is in flight. */ resuming: boolean @@ -43,6 +47,18 @@ export type StructuredAgentSessionQueuedMessagesController = { edit: (messageId: string) => Promise /** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */ steerNewest: () => boolean + /** Present while the header row shows and the queue could send now: the composer offers + * Resume. A failure is a toast, and Resume stays the retry. */ + queueResume: StructuredAgentSessionQueueResume | undefined + /** Present while `queueResume` is: a new message first asks whether to clear the cards. */ + queueHold: NativeChatQueueHold | undefined +} + +export type StructuredAgentSessionQueueResume = { + /** Whether the host lifted the pause (`resume`). */ + resume: () => Promise + /** A Resume is in flight. */ + resuming: boolean } function alreadySentNotice(): void { @@ -58,19 +74,21 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] hasPendingPrompt: boolean + /** A turn is running, whoever started it, or the queue is about to send its next card. */ + isWorking: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - const pause = args.queuePause + const { queuePause } = args const cards = useMemo( () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, - queuePaused: pause !== null + queuePaused: queuePause !== null }), - [hasPendingPrompt, pause, queuedMessages, submissions] + [hasPendingPrompt, queuePause, queuedMessages, submissions] ) const cardsRef = useRef(cards) useEffect(() => { @@ -106,9 +124,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { [actOnce, mutate] ) - const remove = useCallback( - (messageId: string): Promise => - actOnce(messageId, async () => { + /** True once the card has left the queue, deleted or already sent; a failure is a toast. */ + const removeCard = useCallback( + async (messageId: string): Promise => { + let removed = false + await actOnce(messageId, async () => { const result = await mutate( 'agentSession.queuedMessageDelete', 'agentSession.queuedMessageDelete', @@ -117,9 +137,18 @@ export function useStructuredAgentSessionQueuedMessages(args: { if (result && !result.deleted && result.disposition === 'dispatched') { alreadySentNotice() } - }), + removed = result !== null + }) + return removed + }, [actOnce, mutate] ) + const remove = useCallback( + async (messageId: string): Promise => { + await removeCard(messageId) + }, + [removeCard] + ) const edit = useCallback( (messageId: string): Promise => @@ -154,6 +183,18 @@ export function useStructuredAgentSessionQueuedMessages(args: { [actOnce, composerScopeKey, mutate] ) + const steerNewest = useCallback((): boolean => { + if (!enabled) { + return false + } + const newest = newestSteerableQueuedMessageCard(cardsRef.current) + if (!newest) { + return false + } + void steer(newest.messageId) + return true + }, [enabled, steer]) + const resumingRef = useRef(false) const [resuming, setResuming] = useState(false) const resume = useCallback(async (): Promise => { @@ -174,18 +215,28 @@ export function useStructuredAgentSessionQueuedMessages(args: { setResuming(false) } }, [mutate]) + const pause = useMemo(() => queuedMessagesQueuePause(cards, queuePause), [cards, queuePause]) + // Resume and the "Send message?" choice only where the queue could send now: no turn runs (the + // queue's coming send counts, as the host names it) and no prompt waits, which holds the queue + // too; the composer shows beside a prompt only when this build cannot answer it. + const held = enabled && pause !== null && !args.isWorking && !hasPendingPrompt + const queueResume = useMemo( + () => (held ? { resume, resuming } : undefined), + [held, resume, resuming] + ) - const steerNewest = useCallback((): boolean => { - if (!enabled) { - return false - } - const newest = newestSteerableQueuedMessageCard(cardsRef.current) - if (!newest) { - return false - } - void steer(newest.messageId) - return true - }, [enabled, steer]) + // Every card shown, held or not: Clear queue empties the list the person sees. One at a time, + // stopping at the first failure, so one failed press is one toast. + const clear = useCallback( + (): Promise => + cardsRef.current.reduce>( + (previous, card) => previous.then((removed) => removed && removeCard(card.messageId)), + Promise.resolve(true) + ), + [removeCard] + ) + const count = cards.length + const queueHold = useMemo(() => (held ? { count, clear } : undefined), [held, count, clear]) return { cards, @@ -196,6 +247,8 @@ export function useStructuredAgentSessionQueuedMessages(args: { steer, remove, edit, - steerNewest + steerNewest, + queueResume, + queueHold } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index 9e7a833ae1fa..7a40ac48a007 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -24,6 +24,10 @@ export function useStructuredAgentSessionTransportState( const turnId = runningStructuredAgentSessionTurnId({ items: journalItems, latestTurn }) // The rule the host projects every session list's Working from, so this chat cannot disagree. const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, submissions, fence) + const nextQueuedMessageId = (enabled ? state.nextQueuedMessageId : null) ?? null + // The host names the card its queue sends next. That send lands in a later update than a turn's + // end or a Resume, so until then the chat still reads as working and nothing flips in between. + const queueSendsNext = nextQueuedMessageId !== null && !isWorking const turnActivity = useMemo( () => selectStructuredAgentTurnActivity(journalItems, turnId, enabled ? state.activity : null), [enabled, journalItems, state.activity, turnId] @@ -50,6 +54,8 @@ export function useStructuredAgentSessionTransportState( // null = no drafts or no claim; the projection treats both as an empty list. queuedMessages: (enabled ? state.queuedMessages : null) ?? null, queuePause: (enabled ? state.queuePause : null) ?? null, + /** Working only because the queue is about to send: nothing is in flight to stop yet. */ + queueSendsNext, backgroundTasks: structuredSessionBackgroundTasksView( enabled ? state.backgroundTasks : null, turnId diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index a76a6c008e88..16654cd9dea2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ })) let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined +let nextQueuedMessageId: string | null = null let outboxEntries: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -39,7 +40,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ status: 'ready', error: null, hasOlder: false, - ...(queuedMessages !== undefined ? { queuedMessages } : {}) + ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId } : {}) }, loadingOlder: false, loadOlder: vi.fn() @@ -150,6 +151,7 @@ beforeEach(() => { ) items = [RUNNING_TURN] queuedMessages = undefined + nextQueuedMessageId = null outboxEntries = [] localStorage.clear() clearNativeChatDraftCacheForTests() @@ -334,6 +336,20 @@ describe('against a capable host', () => { expect(JSON.stringify(idleUnheld.result.current.messages)).toContain('awaiting the answer') }) + it('reads as working while the host names the card its queue sends next, with nothing to stop yet', () => { + // A turn just ended; the queue's send of `draft-1` is the host's next update. + items = [] + queuedMessages = [draft('draft-1')] + nextQueuedMessageId = 'draft-1' + const { result } = render() + expect(result.current).toMatchObject({ isWorking: true, queueSendsNext: true, canStop: false }) + expect(result.current.queuedMessages.cards.map((card) => card.hold)).toEqual(['turn']) + // Where the host would refuse that send, it names none: the chat reads idle. + nextQueuedMessageId = null + const refused = render() + expect(refused.result.current).toMatchObject({ isWorking: false, queueSendsNext: false }) + }) + it('shows host-held drafts as cards, never as transcript bubbles', () => { queuedMessages = [draft('draft-1')] const { result } = render() 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 f685b5041e20..7531d8dce6e3 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 @@ -118,7 +118,6 @@ export function useStructuredAgentSession(args: { }) // 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( () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] @@ -194,7 +193,7 @@ export function useStructuredAgentSession(args: { (transportState.isWorking || hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. - const isWorking = transportState.isWorking + const isWorking = transportState.isWorking || transportState.queueSendsNext const transcriptOutbox = useMemo( () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery), [isWorking, outbox, queueDelivery, queuedMessageIds] @@ -213,11 +212,11 @@ export function useStructuredAgentSession(args: { transportState.submissions ) const queuedController = useStructuredAgentSessionQueuedMessages({ - enabled: queueCapable && transportState.fence !== null, - queuedMessages: transportState.queuedMessages, - queuePause: transportState.queuePause, - submissions: transportState.submissions, + // Its published list, pause and submissions; the rest is named below. + ...transportState, + enabled: queueCapability === 'supported' && transportState.fence !== null, hasPendingPrompt: prompts.length > 0, + isWorking, composerScopeKey, mutate }) @@ -266,7 +265,8 @@ export function useStructuredAgentSession(args: { rewind.admitsSend() && outboxController.send(...input), retry: rewind.unlessBlocked(outboxController.retry), - isWorking: transportState.isWorking, + isWorking, + queueSendsNext: transportState.queueSendsNext, workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, turnActivity: transportState.turnActivity, diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts index fa169df9f85d..5e076f58dbf3 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts @@ -20,7 +20,9 @@ it('reveals before Retry, but waits for a successful queue Resume', async () => steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - steerNewest: vi.fn(() => false) + steerNewest: vi.fn(() => false), + queueResume: undefined, + queueHold: undefined } } const retryLaunch = (): number => order.push('launch') diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts index 6b93ad94c51d..1a6bf36cc7cb 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts @@ -49,21 +49,25 @@ export function useStructuredNativeChatSubmitReveal( }, [respond, revealLatest] ) - const revealingQueue = useMemo( - () => ({ + const revealingQueue = useMemo(() => { + // Resume can be refused or find nothing paused, so it reveals only once the host lifts it. + const revealing = (lift: () => Promise) => async (): Promise => { + const resumed = await lift() + if (resumed) { + revealLatest() + } + return resumed + } + const { queueResume } = queuedMessages + return { ...queuedMessages, steer: (messageId) => { revealLatest() return queuedMessages.steer(messageId) }, - // Resume can be refused or find nothing paused, so it reveals only once the host lifts it. - resume: async () => { - const resumed = await queuedMessages.resume() - if (resumed) { - revealLatest() - } - return resumed - }, + resume: revealing(queuedMessages.resume), + // The composer's Resume is the same press. + queueResume: queueResume && { ...queueResume, resume: revealing(queueResume.resume) }, steerNewest: () => { const steered = queuedMessages.steerNewest() if (steered) { @@ -71,9 +75,8 @@ export function useStructuredNativeChatSubmitReveal( } return steered } - }), - [queuedMessages, revealLatest] - ) + } + }, [queuedMessages, revealLatest]) return { messageListRef, revealLatest, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 7a0ed36def40..a36edc792f1c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2831,6 +2831,10 @@ "question": { "other": "Other…" }, + "queuedMessages": { + "sendConfirmBody_one": "You are about to send a message. Do you want to clear the {{count}} message previously queued?", + "sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?" + }, "state": { "empty": { "subtitle": "Ask {{value0}} to inspect code, explain output, or make a change.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e79ad7a56bca..c059b3881bb0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18358,10 +18358,15 @@ "pausedKept": "Not sent yet — press Send to send it.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", - "queuePausedRestarted": "Queue paused because Orca restarted", "queuePausedCleared": "Queue paused after you cleared the conversation", "queuePaused": "Queue paused", "resume": "Resume", + "sendConfirmTitle": "Send message?", + "sendConfirmBody": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?", + "sendConfirmBody_one": "You are about to send a message. Do you want to clear the {{count}} message previously queued?", + "sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?", + "sendConfirmClear": "Clear queue", + "sendConfirmSend": "Send message", "from": "From {{names}}" }, "thought": "Thought", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4a0ffda0913b..b61966609bcd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17976,10 +17976,15 @@ "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ó", "queuePausedCleared": "Cola en pausa después de que borraste la conversación", "queuePaused": "Cola en pausa", "resume": "Reanudar", + "sendConfirmTitle": "¿Enviar mensaje?", + "sendConfirmBody": "Estás a punto de enviar un mensaje. ¿Quieres borrar los {{count}} mensajes que había en cola?", + "sendConfirmBody_one": "Estás a punto de enviar un mensaje. ¿Quieres borrar {{count}} mensaje que había en cola?", + "sendConfirmBody_other": "Estás a punto de enviar un mensaje. ¿Quieres borrar los {{count}} mensajes que había en cola?", + "sendConfirmClear": "Vaciar cola", + "sendConfirmSend": "Enviar mensaje", "from": "De {{names}}" }, "thought": "Pensó", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 120650649cf3..1b8f2bf64843 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18206,10 +18206,15 @@ "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é", "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", "queuePaused": "File d'attente en pause", "resume": "Reprendre", + "sendConfirmTitle": "Envoyer le message ?", + "sendConfirmBody": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer les {{count}} messages déjà en file d'attente ?", + "sendConfirmBody_one": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer {{count}} message déjà en file d'attente ?", + "sendConfirmBody_other": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer les {{count}} messages déjà en file d'attente ?", + "sendConfirmClear": "Vider la file d'attente", + "sendConfirmSend": "Envoyer le message", "from": "De {{names}}" }, "thought": "A réfléchi", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 764a792f0422..4b74c4f81536 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18140,10 +18140,15 @@ "pausedKept": "まだ送信されていません。「送信」を押すと送信されます。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", "resume": "再開", + "sendConfirmTitle": "メッセージを送信しますか?", + "sendConfirmBody": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmBody_one": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmBody_other": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmClear": "キューを消去", + "sendConfirmSend": "メッセージを送信", "from": "送信元: {{names}}" }, "thought": "考えました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7c5525ae7a5d..6be21e8dcd53 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18140,10 +18140,15 @@ "pausedKept": "아직 보내지 않았습니다. 보내기를 눌러 보내세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", "resume": "재개", + "sendConfirmTitle": "메시지를 보낼까요?", + "sendConfirmBody": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmBody_one": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmBody_other": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmClear": "대기열 지우기", + "sendConfirmSend": "메시지 보내기", "from": "보낸 사람: {{names}}" }, "thought": "생각함", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 59c73df48b43..0091d603c08e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18105,10 +18105,15 @@ "pausedKept": "尚未发送。点按发送即可发送。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", "resume": "继续", + "sendConfirmTitle": "发送消息?", + "sendConfirmBody": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmBody_one": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmBody_other": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmClear": "清空队列", + "sendConfirmSend": "发送消息", "from": "来自 {{names}}" }, "thought": "已思考", diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 3ce38f6057ce..c335e2f857f0 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -491,8 +491,8 @@ export type AgentJournalSubmission = { * a draft id compared with `clientMessageId`. */ queuedMessageId?: string /** 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. The snapshot still carries it; no released - * client reads it. */ + * A restart or a close keeps only a person's Send cut short as a card. The snapshot still + * carries it; no released client reads it. */ origin?: 'client' | 'host' /** Who it is from: the kind of its `AgentSessionMessageSource` ('user' or 'agent'), so a restart * or a close keeps only a person's unsent send as a card. Only the kind: the senders stay on the diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index 6a2829476d6f..a1df60b85f6f 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -16,13 +16,21 @@ export type AgentSessionQueuedMessagePausedReason = | 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 - * carried the cards into a fresh conversation — or 'restarted' — Orca restarted - * with cards waiting. Resume (`agentSession.queuedMessagesResume`), or the user's own next - * turn starting, lifts it; Send-now on one card sends that card and leaves the + * interrupted ("Queue paused because you interrupted") — or 'cleared' — a /clear + * carried the cards into a fresh conversation. Resume (`agentSession.queuedMessagesResume`), + * or any turn starting, lifts it; Send-now on one card sends that card and leaves the * rest paused until its turn starts. A client treats an unknown reason as a * plain pause, so a newer host can add one. */ -export type AgentSessionQueuePause = { reason: 'stopped' | 'restarted' | 'cleared' } +export type AgentSessionQueuePause = { reason: 'stopped' | 'cleared' } + +/** What rides beside a frame's `queuedMessages`, published together with the list. */ +export type AgentSessionQueuePublicationFields = { + /** Null when the queue sends on its own. */ + queuePause?: AgentSessionQueuePause | null + /** The card the queue sends next once nothing runs, null while anything holds the queue. + * Absent from an older host, read as null. */ + nextQueuedMessageId?: string | null +} export type AgentSessionQueuedMessagesResumeResult = { /** False when nothing was paused, and on a replay of an already-run Resume. */ diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 8ce2caa1781a..c09db3e9261b 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -7,7 +7,8 @@ import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import type { AgentChildWorkView } from './agent-status-child-work-view' import type { AgentSessionQueuedMessage, - AgentSessionQueuePause + AgentSessionQueuePause, + AgentSessionQueuePublicationFields } from './agent-session-queued-message-wire' export * from './agent-session-wire-refusals' @@ -135,6 +136,9 @@ export type AgentSessionHistoryPage = { /** The queue's pause, published with the list: present whenever `queuedMessages` is, null * when the queue sends on its own. */ queuePause?: AgentSessionQueuePause | null + /** Rides with `queuedMessages`: the card the queue sends next as soon as nothing runs, null + * while anything holds the queue. Absent from an older host, read as null. */ + nextQueuedMessageId?: string | null /** Host wall clock (ms epoch) when the page was read, so a client attaching mid-turn * can anchor a live counter on the real start. Absent from older hosts. */ hostNow?: number @@ -167,8 +171,9 @@ export type AgentSessionJournalBatch = { submissions: AgentJournalSubmission[] } -/** Host wall clock (ms epoch) stamped once per published frame; see `AgentSessionHistoryPage`. */ -type AgentSessionHostClockField = { hostNow?: number } +/** Every published frame: the host wall clock (ms epoch, see `AgentSessionHistoryPage`), and what + * rides beside its `queuedMessages`. */ +type AgentSessionFrameFields = { hostNow?: number } & AgentSessionQueuePublicationFields export type AgentSessionSubscribeEvent = | ({ @@ -179,13 +184,11 @@ export type AgentSessionSubscribeEvent = backgroundTasks?: AgentSessionBackgroundTaskState | null /** Whole-list draft publication; omitted when unchanged since the last frame sent. */ queuedMessages?: AgentSessionQueuedMessage[] | null - /** Rides with `queuedMessages`; null when the queue sends on its own. */ - queuePause?: AgentSessionQueuePause | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null /** Latest provider-authored turn activity; optional for mixed-version hosts. */ activity?: AgentSessionTurnActivity | null - } & AgentSessionHostClockField) + } & AgentSessionFrameFields) | ({ type: 'batch' sessionId: string @@ -196,8 +199,6 @@ export type AgentSessionSubscribeEvent = /** Whole-list draft publication. On a multi-page catch-up it rides only the * final page, so a consumed card never vanishes before its bubble arrives. */ queuedMessages?: AgentSessionQueuedMessage[] | null - /** Rides with `queuedMessages`; null when the queue sends on its own. */ - queuePause?: AgentSessionQueuePause | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null /** Additive ephemeral state; it never creates or advances journal rows. */ @@ -205,7 +206,7 @@ export type AgentSessionSubscribeEvent = /** Rides every batch that carries rows, removals or submissions, so absent there means an * older host; absent on one that carries none, which changes no turn. */ latestTurn?: AgentSessionLatestTurn | null - } & AgentSessionHostClockField) + } & AgentSessionFrameFields) | ({ type: 'reset' sessionId: string @@ -215,12 +216,10 @@ export type AgentSessionSubscribeEvent = backgroundTasks?: AgentSessionBackgroundTaskState | null /** Whole-list draft publication; a reset re-hydrates it with the page. */ queuedMessages?: AgentSessionQueuedMessage[] | null - /** Rides with `queuedMessages`; null when the queue sends on its own. */ - queuePause?: AgentSessionQueuePause | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null - } & AgentSessionHostClockField) + } & AgentSessionFrameFields) | { type: 'end' } // ─── Status feed ──────────────────────────────────────────────────────────── diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 8b3d7b38df29..56fe9decc2e7 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -235,9 +235,12 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // 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; 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. +// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or any turn sent +// after it starting; while it is set every waiting card waits. `nextQueuedMessageId` beside the list +// names the card the queue sends next once nothing runs, null while anything holds it; absent (an +// older host) reads as null. A card carries a hold of its own when its conversion failed +// (`send_failed`) or the host kept it unsent (`kept`). The host mechanism lands first; the constant +// gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 761ea14197e1..08dbf9ed1f98 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -115,10 +115,10 @@ describe('structured agent session event coalescer', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) - coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'restarted' } }) + coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'cleared' } }) coalescer.push(batch(2)) coalescer.flush() - expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'restarted' } }) + expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'cleared' } }) coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) @@ -126,6 +126,27 @@ describe('structured agent session event coalescer', () => { expect(events[1]).toMatchObject({ queuePause: { reason: 'stopped' } }) }) + it("keeps the queue's next card with its list when a token batch merges into it", () => { + const events: AgentSessionSubscribeEvent[] = [] + const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) + coalescer.push({ ...batch(1), queuedMessages: [], nextQueuedMessageId: 'draft-1' }) + const token = { + itemId: 'assistant-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'streaming' }] + } + } + const tokens = batch(2) + coalescer.push({ ...tokens, batch: { ...tokens.batch, items: [token] } }) + coalescer.flush() + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ nextQueuedMessageId: 'draft-1' }) + }) it('keeps the latest turn a coalesced frame carried, and a null one as an answer', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index 94442408eb39..a19e584af9b8 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -10,6 +10,15 @@ function bypassCoalescing(event: AgentSessionSubscribeEvent): boolean { ) } +/** The list and what rides with it. */ +function queuePublicationOf(event: Extract) { + return { + queuedMessages: event.queuedMessages, + queuePause: event.queuePause ?? null, + nextQueuedMessageId: event.nextQueuedMessageId ?? null + } +} + function mergeBatch( left: Extract, right: Extract @@ -34,9 +43,9 @@ function mergeBatch( // Whole-list publication, latest wins: dropping it here would lose a draft // update that rode a coalesced token frame. The pause rides with its list. ...(right.queuedMessages !== undefined - ? { queuedMessages: right.queuedMessages, queuePause: right.queuePause ?? null } + ? queuePublicationOf(right) : left.queuedMessages !== undefined - ? { queuedMessages: left.queuedMessages, queuePause: left.queuePause ?? null } + ? queuePublicationOf(left) : {}), sessionId: right.sessionId, batch: { diff --git a/src/shared/structured-agent-session-queue-publication-field.ts b/src/shared/structured-agent-session-queue-publication-field.ts new file mode 100644 index 000000000000..a183fd119491 --- /dev/null +++ b/src/shared/structured-agent-session-queue-publication-field.ts @@ -0,0 +1,29 @@ +// The queue's publication as the client reducer keeps it: the draft list and what rides with it. + +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from './agent-session-queued-message-wire' + +export type StructuredAgentSessionQueuePublication = { + queuedMessages?: AgentSessionQueuedMessage[] | null + queuePause?: AgentSessionQueuePause | null + nextQueuedMessageId?: string | null +} + +/** First claim with a list wins, and what rides with it comes from that claim; no claim at all + * leaves all absent (older host). */ +export function queuePublicationField( + ...claims: StructuredAgentSessionQueuePublication[] +): StructuredAgentSessionQueuePublication { + for (const claim of claims) { + if (claim.queuedMessages !== undefined) { + return { + queuedMessages: claim.queuedMessages, + queuePause: claim.queuePause ?? null, + nextQueuedMessageId: claim.nextQueuedMessageId ?? null + } + } + } + return {} +} diff --git a/src/shared/structured-agent-session-reducer.queued-messages.test.ts b/src/shared/structured-agent-session-reducer.queued-messages.test.ts index dd807f584558..d6ea3929f08e 100644 --- a/src/shared/structured-agent-session-reducer.queued-messages.test.ts +++ b/src/shared/structured-agent-session-reducer.queued-messages.test.ts @@ -46,7 +46,8 @@ function page(queuedMessages?: AgentSessionQueuedMessage[] | null): AgentSession function snapshot( queuedMessages?: AgentSessionQueuedMessage[] | null, - queuePause?: AgentSessionQueuePause | null + queuePause?: AgentSessionQueuePause | null, + nextQueuedMessageId?: string | null ): AgentSessionSubscribeEvent { return { type: 'snapshot', @@ -54,7 +55,8 @@ function snapshot( page: page(), fence: 1, ...(queuedMessages !== undefined ? { queuedMessages } : {}), - ...(queuePause !== undefined ? { queuePause } : {}) + ...(queuePause !== undefined ? { queuePause } : {}), + ...(nextQueuedMessageId !== undefined ? { nextQueuedMessageId } : {}) } } @@ -175,6 +177,18 @@ describe('structured agent session reducer: queuedMessages', () => { expect(resumed.queuePause).toBeNull() }) + it("keeps the queue's next card with the list it rode with, from a snapshot and a batch", () => { + const named = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: snapshot([queued('draft-1', 1)], null, 'draft-1') + }) + expect(named.nextQueuedMessageId).toBe('draft-1') + const kept = reduceStructuredAgentSession(named, { type: 'event', event: batch(2) }) + expect(kept.nextQueuedMessageId).toBe('draft-1') + const sent = reduceStructuredAgentSession(kept, { type: 'event', event: batch(3, []) }) + expect(sent.nextQueuedMessageId).toBeNull() + }) + it('changes state for a pause-only update at an unchanged cursor, journal and list', () => { const state = reduceStructuredAgentSession(hydrated([queued('draft-1', 1)]), { type: 'event', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index e8c78166a2ea..6ff6124feb38 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -25,6 +25,7 @@ import { } from './structured-agent-session-item-retention' import { compareAgentJournalItems } from './agent-session-journal-position' import { readAgentJournalTurn } from './agent-session-turn-record' +import { queuePublicationField } from './structured-agent-session-queue-publication-field' import { latestTurnAfterStructuredAgentSessionBatch } from './structured-agent-session-live-turn' import { foldStructuredAgentSubagentRoster, @@ -62,6 +63,8 @@ export type StructuredAgentSessionState = { queuedMessages?: AgentSessionQueuedMessage[] | null /** The queue's pause, published with the list; null when it sends on its own. */ queuePause?: AgentSessionQueuePause | null + /** Published with the list: the card the queue sends next once nothing runs, else null. */ + nextQueuedMessageId?: string | null commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null /** Absent until a frame from a host that stamps `hostNow` has been applied. */ @@ -108,19 +111,6 @@ function hostClockField( return hostClock ? { hostClock } : {} } -type QueuePublication = Pick - -/** First claim with a list wins, and its pause rides with it; no claim at all leaves both absent - * (older host). */ -function queuePublicationField(...claims: QueuePublication[]): QueuePublication { - for (const claim of claims) { - if (claim.queuedMessages !== undefined) { - return { queuedMessages: claim.queuedMessages, queuePause: claim.queuePause ?? null } - } - } - return {} -} - function replacePage( page: AgentSessionHistoryPage, fence: number | null, @@ -282,6 +272,8 @@ export function reduceStructuredAgentSession( (event.commands === undefined || event.commands === state.commands) && (event.queuedMessages === undefined || event.queuedMessages === state.queuedMessages) && (event.queuePause === undefined || event.queuePause === state.queuePause) && + (event.nextQueuedMessageId === undefined || + event.nextQueuedMessageId === state.nextQueuedMessageId) && backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) && activity?.turnId === state.activity?.turnId && activity?.text === state.activity?.text &&