From 5f44ff7bc5798a0af75e69780dcb629a6e74a600 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:37:23 -0700 Subject: [PATCH 01/31] fix(native-chat): drop the queue-paused header and Resume button A Stop, a restart or /clear holds the queued cards. The hold stays; only the header row naming why, and its Resume button, go. A held card shows no caption, and its own Steer, or any new message, releases the queue. --- .../NativeChatQueuedMessageCard.tsx | 6 +- .../NativeChatQueuedMessageList.test.tsx | 155 +++++++----------- .../NativeChatQueuedMessageList.tsx | 63 +------ .../structured-agent-session-queued-cards.ts | 4 +- ...nt-session-queued-messages.resume.test.tsx | 125 -------------- ...tructured-agent-session-queued-messages.ts | 43 +---- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- 12 files changed, 78 insertions(+), 360 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 29432e643304..690c69ae3d09 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -52,8 +52,8 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) } case 'paused': - // A card's own hold; the queue's pause is the list's header. Markers localize, and an absent - // or unknown one (newer host) is a plain pause, never shown raw. + // A card's own hold. Markers localize; an absent or unknown one (newer host) is a plain + // pause, never shown raw. if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return translate( 'components.native-chat.queuedMessages.pausedSendFailed', @@ -73,7 +73,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) case 'turn': case 'queue-paused': - // Plainly queued; a paused queue's header row carries the why. + // Plainly queued: a held queue shows no caption. return null } } diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 41fd4852a202..9d4f704fec0b 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,12 @@ 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' function renderList(owner: StructuredAgentSessionQueuedMessagesController) { // The app root mounts the provider; tests supply the same context. @@ -44,20 +50,13 @@ function card(overrides: Partial & { messageId: string }): Qu } } -function controller( - cards: QueuedMessageCard[], - pause: { reason: string } | null = null -): StructuredAgentSessionQueuedMessagesController & { +function controller(cards: QueuedMessageCard[]): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType edit: ReturnType - resume: ReturnType } { return { cards, - pause, - resume: vi.fn(async () => {}), - resuming: false, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), @@ -234,76 +233,50 @@ 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) - // The row sits above the list, not inside it: it is not a queued message. - expect(within(screen.getByRole('list')).queryByText(text)).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Resume' })) - expect(owner.resume).toHaveBeenCalledTimes(1) - view.unmount() - } - }) - - it('no header row when the queue drains on its own, or when there are no cards', () => { - renderList(controller([card({ messageId: 'waiting' })], null)) - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - cleanup() - const { container } = renderList(controller([], { 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' } + it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( + "a queue the host holds ('%s') shows no header and no Resume; each card's Steer sends it", + (reason) => { + const mutate = vi.fn(async (..._call: [string, string, Record]) => null) + const waiting: AgentSessionQueuedMessage = { + messageId: 'held', + position: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'held text' }] }, + state: 'waiting' + } + function HeldQueue(): React.JSX.Element { + const owner = useStructuredAgentSessionQueuedMessages({ + enabled: true, + queuedMessages: [waiting], + queuePause: { reason }, + submissions: [], + hasPendingPrompt: 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 { container } = render( + + + + ) + expect(container.textContent).not.toContain('Queue paused') + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + const list = screen.getByRole('list') + // The box holds only the list: no row above the cards. + expect(list.parentElement?.children).toHaveLength(1) + const row = within(list).getByRole('listitem') + // The text is the card's only line: no caption under it. + expect(row.querySelectorAll('p')).toHaveLength(1) + fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + expect(mutate).toHaveBeenCalledWith( + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageSend', + { messageId: 'held' } ) - ) - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - }) - - it('Resume shows it is pending, keeps the full text for a truncated line, and hands focus back', async () => { - const focusComposer = vi.fn() - const owner = { - ...controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason: 'stopped' }), - resuming: true } - render( - - - - ) - const resume = screen.getByRole('button', { name: 'Resume' }) - expect(resume).toHaveProperty('disabled', true) - expect(screen.getByTitle('Queue paused because you interrupted')).toBeTruthy() - cleanup() - const idle = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { - reason: 'stopped' - }) - render( - - - - ) - const enabled = screen.getByRole('button', { name: 'Resume' }) - enabled.focus() - fireEvent.click(enabled) - await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) - }) + ) it('Steer carries the ↳ icon; a card leads with the queue glyph, a failed one with the alert', () => { const { container } = renderList( @@ -326,26 +299,20 @@ describe('NativeChatQueuedMessageList', () => { expect(container.querySelectorAll('.lucide-list-end')).toHaveLength(1) }) - it('one bordered box holds the pause row and every card, rows divided, cards unbordered', () => { + it('one bordered box holds every card, rows divided, cards unbordered', () => { renderList( - controller( - [ - card({ messageId: 'waiting', hold: 'queue-paused' }), - card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), - card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) - ], - { reason: 'stopped' } - ) + controller([ + card({ messageId: 'waiting', hold: 'queue-paused' }), + card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) + ]) ) const list = screen.getByRole('list') const box = list.parentElement! expect(box.className.split(' ')).toEqual( - expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card', 'divide-y']) + expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card']) ) - // The pause row is the box's first row, outside the list; the list is its second. - expect(box.children).toHaveLength(2) - expect(box.firstElementChild?.textContent).toContain('Queue paused because you interrupted') - expect(box.lastElementChild).toBe(list) + expect(box.children).toHaveLength(1) expect(list.className.split(' ')).toContain('divide-y') const rows = within(list).getAllByRole('listitem') expect(rows).toHaveLength(3) @@ -357,7 +324,7 @@ describe('NativeChatQueuedMessageList', () => { expect(rows[2]?.textContent).toContain("Couldn't send — press Send to retry.") }) - it('without a pause the box holds only the list, one card or many', () => { + it('the box holds only the list, one card or many', () => { for (const count of [1, 4]) { const view = renderList( controller( @@ -384,9 +351,7 @@ describe('NativeChatQueuedMessageList', () => { }) it('cards keep Steer, Delete and More actions while the queue is paused', () => { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { - reason: 'stopped' - }) + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })]) renderList(owner) const row = screen.getByRole('listitem') expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 17223e0194cb..1483681ca636 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -1,6 +1,4 @@ import { useRef } from 'react' -import { Pause, Play } from 'lucide-react' -import { Button } from '@/components/ui/button' import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' @@ -23,11 +21,7 @@ export function NativeChatQueuedMessageList({ const queueRef = useRef(null) const { cards } = controller const newest = cards.at(-1) - // 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. + // Only when focus was on the queue — never pull it from wherever the user moved on to. const refocusAfter = (action: Promise): void => { void action.then(() => { const active = document.activeElement @@ -40,15 +34,7 @@ export function NativeChatQueuedMessageList({
{cards.length > 0 ? (
- {/* One box: the pause row, when shown, is its first row, and each card a row below it. */}
- {pause ? ( - refocusAfter(controller.resume())} - /> - ) : null}
    ) } - -/** Why the queue sends nothing on its own. An unknown reason (a newer host) is a plain pause. */ -function queuePauseText(pause: { reason: string }): string { - switch (pause.reason) { - case 'stopped': - return translate( - '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', - 'Queue paused after you cleared the conversation' - ) - default: - return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') - } -} - -function NativeChatQueuePauseRow({ - pause, - resuming, - onResume -}: { - pause: { reason: string } - resuming: boolean - onResume: () => void -}): React.JSX.Element { - const text = queuePauseText(pause) - return ( -
    - -

    - {text} -

    - -
    - ) -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e9d1f394b9af..26189f19851b 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 @@ -18,8 +18,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** The whole queue is paused: the header row says why and offers Resume, so the card makes - * no promise about when it sends — not even after an answer, which does not drain it. */ + /** Held since a Stop, restart or /clear: no caption and no promise of when it sends — not even + * after an answer. Its Steer, or any new send, releases it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' 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 deleted file mode 100644 index fa4ae91b1eae..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// @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. - -import { act, renderHook } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -type ResumeParams = { envelope: { clientOperationId: string } } - -const mocks = vi.hoisted(() => ({ - call: vi.fn<(target: unknown, method: string, params: ResumeParams) => Promise>(), - toastError: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call -})) - -import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' -import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' - -function renderController() { - const stateRef = { current: { fence: 1 } } - return renderHook(() => { - const { mutate } = useStructuredAgentSessionMutate({ - sessionId: 'session-1', - target: { kind: 'local' }, - stateRef - }) - return useStructuredAgentSessionQueuedMessages({ - enabled: true, - queuedMessages: [], - queuePause: { reason: 'stopped' }, - submissions: [], - hasPendingPrompt: false, - composerScopeKey: undefined, - mutate - }) - }) -} - -afterEach(() => { - vi.clearAllMocks() -}) - -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 } - }) - const { result } = renderController() - expect(result.current.pause).toEqual({ reason: 'stopped' }) - await act(() => result.current.resume()) - expect(mocks.call).toHaveBeenCalledTimes(1) - const [, method, params] = mocks.call.mock.calls[0] ?? [] - expect(method).toBe('agentSession.queuedMessagesResume') - expect(Object.keys(params ?? {})).toEqual(['envelope']) - expect(mocks.toastError).not.toHaveBeenCalled() - }) - - it('a refused or failed Resume is one toast', async () => { - mocks.call.mockResolvedValueOnce({ - ok: false, - refusal: { code: 'agent_session_conflict', message: 'The session moved on.' } - }) - mocks.call.mockRejectedValueOnce(new Error('socket closed')) - const { result } = renderController() - await act(() => result.current.resume()) - expect(mocks.toastError).toHaveBeenCalledTimes(1) - await act(() => result.current.resume()) - 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. - 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 } - }) - const { result } = renderController() - for (let press = 0; press < 3; press += 1) { - await act(() => result.current.resume()) - } - 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 = Promise.resolve() - act(() => { - pending = result.current.resume() - }) - expect(result.current.resuming).toBe(true) - await act(async () => { - answer.resolve({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed: true } - }) - await pending - }) - expect(result.current.resuming).toBe(false) - }) -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 4596fb67180f..c78267e7be34 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 @@ -4,14 +4,13 @@ // 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 { useCallback, useEffect, useMemo, useRef } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, - AgentSessionQueuedMessagesResumeResult, AgentSessionQueuePause, AgentSessionSendResult } from '../../../../shared/agent-session-wire' @@ -25,13 +24,6 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] - /** Why the whole queue sends nothing on its own; null when it drains. Shown only with cards. - * A string reason: a newer host may name one this build does not know. */ - pause: { reason: string } | null - /** Lift the queue's pause; a failure is a toast, and the Resume button is the retry. */ - resume: () => Promise - /** A Resume is in flight. */ - resuming: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -58,15 +50,12 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - const pause = args.queuePause + // Held cards carry no caption; each card's Steer, or any new send, releases them. + const queuePaused = args.queuePause !== null const cards = useMemo( - () => - projectQueuedMessageCards(queuedMessages, submissions, { - hasPendingPrompt, - queuePaused: pause !== null - }), - [hasPendingPrompt, pause, queuedMessages, submissions] + () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePaused }), + [hasPendingPrompt, queuePaused, queuedMessages, submissions] ) const cardsRef = useRef(cards) useEffect(() => { @@ -146,26 +135,6 @@ export function useStructuredAgentSessionQueuedMessages(args: { [actOnce, composerScopeKey, mutate] ) - const resumingRef = useRef(false) - const [resuming, setResuming] = useState(false) - const resume = useCallback(async (): Promise => { - if (resumingRef.current) { - return - } - resumingRef.current = true - setResuming(true) - try { - await mutate( - 'agentSession.queuedMessagesResume', - 'agentSession.queuedMessagesResume', - {} - ) - } finally { - resumingRef.current = false - setResuming(false) - } - }, [mutate]) - const steerNewest = useCallback((): boolean => { if (!enabled) { return false @@ -178,5 +147,5 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, pause, resume, resuming, steer, remove, edit, steerNewest } + return { cards, steer, remove, edit, steerNewest } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 21fcdf4beea5..509775e58baa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17984,12 +17984,7 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", - "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" + "paused": "Paused" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c1dc27fd97d8..ab892d16f77d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15205,12 +15205,7 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", - "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" + "paused": "En pausa" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index cc9d56836894..5e8f910eac1c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17887,12 +17887,7 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", - "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" + "paused": "En pause" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 485f45e6e6fd..4fd83383db22 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17823,12 +17823,7 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", - "paused": "一時停止中", - "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", - "queuePausedCleared": "会話を消去したため、キューを一時停止しました", - "queuePaused": "キューは一時停止中です", - "resume": "再開" + "paused": "一時停止中" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 23f07a9d2e95..36d6900743c5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17823,12 +17823,7 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", - "paused": "일시 중지됨", - "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", - "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", - "queuePaused": "대기열이 일시 중지되었습니다", - "resume": "재개" + "paused": "일시 중지됨" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6b13b751dd66..4f83a417f81e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17788,12 +17788,7 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", - "paused": "已暂停", - "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", - "queuePausedCleared": "清除对话后,队列已暂停", - "queuePaused": "队列已暂停", - "resume": "继续" + "paused": "已暂停" } }, "tab": { From dc181bc219e8099082bf69446e4c719bcc4571f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:39:40 -0700 Subject: [PATCH 02/31] test(native-chat): type the unknown hold reason a newer host may publish --- .../native-chat/NativeChatQueuedMessageList.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 9d4f704fec0b..0d9bda07dd5d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -30,6 +30,7 @@ import { } 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. @@ -236,6 +237,8 @@ describe('NativeChatQueuedMessageList', () => { it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( "a queue the host holds ('%s') shows no header and no Resume; each card's Steer sends it", (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 mutate = vi.fn(async (..._call: [string, string, Record]) => null) const waiting: AgentSessionQueuedMessage = { messageId: 'held', @@ -247,7 +250,7 @@ describe('NativeChatQueuedMessageList', () => { const owner = useStructuredAgentSessionQueuedMessages({ enabled: true, queuedMessages: [waiting], - queuePause: { reason }, + queuePause, submissions: [], hasPendingPrompt: false, composerScopeKey: undefined, From 289a3450582ef19a54f0bc5927acf341b90bd70c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:15:22 -0700 Subject: [PATCH 03/31] fix(native-chat): a held card offers Send, not Steer, when no turn runs Steer vs Send now follows whether a turn is running, not the card's hold, so a card held after a Stop, a restart or /clear reads Send. --- .../NativeChatQueuedMessageCard.tsx | 15 ++++-- .../NativeChatQueuedMessageList.test.tsx | 47 +++++++++++-------- .../NativeChatQueuedMessageList.tsx | 1 + ...tiveChatStructuredSession.test-harness.tsx | 1 + ...tiveChatStructuredSessionDelivery.test.tsx | 1 + ...red-agent-session-queued-messages.test.tsx | 1 + ...tructured-agent-session-queued-messages.ts | 6 ++- .../use-structured-agent-session.ts | 1 + 8 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 690c69ae3d09..f4872c08120f 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -78,15 +78,18 @@ 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. */ -export function queuedMessageCardSendNow(card: QueuedMessageCard): { +/** Steer names the jump into a running turn; with no turn running, or for a card held on its own + * or returned, the action and tooltip are plainly Send. */ +export function queuedMessageCardSendNow( + card: QueuedMessageCard, + turnRunning: boolean +): { /** Steer's ↳, or Send's paper plane. */ steers: boolean label: string hint: string } { - if (card.hold === 'paused' || card.hold === 'returned') { + if (!turnRunning || card.hold === 'paused' || card.hold === 'returned') { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), @@ -105,6 +108,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, + turnRunning, showsSteerShortcut, onSteer, onDelete, @@ -112,6 +116,7 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard + turnRunning: boolean /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean onSteer: () => void @@ -121,7 +126,7 @@ export function NativeChatQueuedMessageCard({ }): React.JSX.Element { const caption = queuedMessageCardCaption(card) const returned = card.state === 'returned' - const sendNow = queuedMessageCardSendNow(card) + const sendNow = queuedMessageCardSendNow(card, turnRunning) const isMac = isMacPlatform() return (
  • & { messageId: string }): Qu } } -function controller(cards: QueuedMessageCard[]): StructuredAgentSessionQueuedMessagesController & { +/** `turnRunning` defaults on: a card waiting on the turn implies one is running. */ +function controller( + cards: QueuedMessageCard[], + turnRunning = true +): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType edit: ReturnType } { return { cards, + turnRunning, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), @@ -235,7 +240,7 @@ describe('NativeChatQueuedMessageList', () => { }) it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( - "a queue the host holds ('%s') shows no header and no Resume; each card's Steer sends it", + "a queue the host holds ('%s') shows no header and no Resume; each card's Send sends it", (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 @@ -253,6 +258,8 @@ describe('NativeChatQueuedMessageList', () => { queuePause, submissions: [], hasPendingPrompt: false, + // Nothing runs after a Stop, a restart or a /clear. + 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 @@ -272,7 +279,8 @@ describe('NativeChatQueuedMessageList', () => { const row = within(list).getByRole('listitem') // The text is the card's only line: no caption under it. expect(row.querySelectorAll('p')).toHaveLength(1) - fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + expect(within(row).queryByRole('button', { name: 'Steer' })).toBeNull() + fireEvent.click(within(row).getByRole('button', { name: 'Send' })) expect(mutate).toHaveBeenCalledWith( 'agentSession.queuedMessageSend', 'agentSession.queuedMessageSend', @@ -353,32 +361,33 @@ describe('NativeChatQueuedMessageList', () => { expect(group.children).toHaveLength(2) }) - it('cards keep Steer, Delete and More actions while the queue is paused', () => { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })]) + it('a held card keeps Send, Delete and More actions; Steer only while a turn runs', () => { + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], false) renderList(owner) const row = screen.getByRole('listitem') - expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() + expect(within(row).queryByRole('button', { name: 'Steer' })).toBeNull() expect(within(row).getByRole('button', { name: 'Delete' })).toBeTruthy() expect(within(row).getByRole('button', { name: 'More actions' })).toBeTruthy() expect(row.querySelectorAll('p')).toHaveLength(1) - fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + const send = within(row).getByRole('button', { name: 'Send' }) + expect(send.querySelector('.lucide-send')).not.toBeNull() + fireEvent.click(send) expect(owner.steer).toHaveBeenCalledWith('waiting') + cleanup() + // A turn Orca started (mail, a restart's continuation) runs while the queue stays held. + renderList(controller([card({ messageId: 'waiting', hold: 'queue-paused' })], true)) + expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy() }) - 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) { - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ - steers: true, - label: 'Steer', - hint: 'Submit without interrupting the model' - }) + it('Steer only while a turn runs, and never for a card held on its own or returned', () => { + const SEND = { steers: false, label: 'Send', hint: 'Send this message now' } + const STEER = { steers: true, label: 'Steer', hint: 'Submit without interrupting the model' } + for (const hold of ['turn', 'awaiting-answer', 'behind-returned', 'queue-paused'] as const) { + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), true)).toEqual(STEER) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), false)).toEqual(SEND) } for (const hold of ['paused', 'returned'] as const) { - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ - steers: false, - label: 'Send', - hint: 'Send this message now' - }) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), true)).toEqual(SEND) } }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 1483681ca636..cd37aeee322d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -46,6 +46,7 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} 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 6e39ec0129de..58035cbc742e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -192,6 +192,7 @@ export function createStructuredSessionMocks() { stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, + turnRunning: mocks.turnId !== null, steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 460df23ef8b3..17567d984d80 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -92,6 +92,7 @@ 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 () => {}), 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 5fdfc0a95c7a..7eb8d08b09c6 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 @@ -51,6 +51,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 c78267e7be34..844c4d1f20ce 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 @@ -24,6 +24,8 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] + /** A turn is running, so Send-now steers into it rather than starting one. */ + turnRunning: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -46,6 +48,8 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] hasPendingPrompt: boolean + /** The main agent is working: a turn is running, whoever started it. */ + isWorking: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { @@ -147,5 +151,5 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, steer, remove, edit, steerNewest } + return { cards, turnRunning: args.isWorking, steer, remove, edit, steerNewest } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 96ad805560d9..6ab22de81963 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 @@ -174,6 +174,7 @@ export function useStructuredAgentSession(args: { queuePause: transportState.queuePause, submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, + isWorking, composerScopeKey, mutate }) From faaefccad938af66883b7cbac57e0f6a45ce2f06 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:17:53 -0700 Subject: [PATCH 04/31] fix(native-chat): the queue sends past held cards instead of stalling behind them A card queued after a Stop (or written after a restart or /clear) sent only once the cards held before it were released; with no header to explain or release the hold, it sat silently. The next sendable card now skips held cards; a returned card still blocks what is behind it. --- .../journal-queued-messages.ts | 2 +- .../queued-message-pause.test.ts | 28 +++++++++++++------ .../queued-message-pause.ts | 14 +++++----- ...tructured-agent-session-queued-messages.ts | 5 ++-- ...ed-agent-session-queued-pause-lift.test.ts | 10 +++---- ...ured-agent-session-queued-stop-row.test.ts | 23 ++++++--------- 6 files changed, 43 insertions(+), 39 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index b8fcafc0b6c0..4a9e0a6b1db3 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 @@ -247,7 +247,7 @@ export class JournalQueuedMessages { if (input.yieldsToPause) { // Judged again here, by the drain's own rule. Today a Stop cannot land between the drain's // pick and this claim (both run on the session's serialized lane, held across the send), so - // this guards any pause-relevant row written off that lane from overtaking a held card. + // this guards any pause-relevant row written off that lane from sending a card now held. const cards = listQueuedMessages(db, this.deps.sessionId) const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index b8e45e0ef590..22855e183596 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 @@ -447,31 +447,33 @@ describe('which cards a pause holds', () => { ]) }) - it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { + it("the queue's own consume takes a newer card past a held one, and refuses the held one", async () => { const journal = await open() await queueDraft(journal, 'held') await userStop(journal) await queueDraft(journal, 'newer') - await expect( + const drain = (messageId: string) => journal.appendSubmission( { - clientMessageId: 'drain-newer', + clientMessageId: `drain-${messageId}`, origin: 'host', - payloadFingerprint: 'fp-newer', - body: message('newer'), + payloadFingerprint: `fp-${messageId}`, + body: message(messageId), fence: 0, handoverRecorded: true }, { - messageId: 'newer', + messageId, expect: 'waiting', settledByOp: null, hostInstance: HOST, yieldsToPause: { hostInstance: HOST } } ) - ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') + await expect(drain('held')).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + await drain('newer') + expect(journal.queuedMessages.get('newer')?.state).toBe('dispatched') + expect(journal.queuedMessages.get('held')?.state).toBe('waiting') }) it("'cleared' holds the carried cards, not one typed after them", async () => { @@ -581,6 +583,16 @@ describe('which cards the pauses in force hold', () => { expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) }) + it('the next card skips held ones, but never a returned card', () => { + const held = card('held', 3) + const after = card('after', 5) + expect(nextSendableQueuedCard(pausesOver([held, after]), [held, after])).toBe(after) + const returned = card('returned', 5, { state: 'returned' }) + const behind = card('behind', 6) + const cards = [held, returned, behind] + expect(nextSendableQueuedCard(pausesOver(cards), cards)).toBeNull() + }) + it("a card names the first pause holding it, and the header names the first held card's", () => { const cards = [ card('before', 3, { hostInstance: DEAD }), diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 9039760ba43c..e0892bf838d6 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 @@ -148,8 +148,8 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { } // Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards -// queued before it, and a steer it withdrew, wait. It still never jumps ahead of a held card: the -// drain stops at the first one. true instead holds every waiting card, whenever it was queued. +// queued before it, and a steer it withdrew, wait. The drain skips held cards, so it sends ahead of +// them. 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 @@ -165,18 +165,18 @@ export function queuePauseHolding( return pauses.find((pause) => PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) } -/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or a held one comes first. The queue never reorders, so a newer card never - * overtakes a held one. The drain's pick and its consume both read this. */ +/** The card the queue sends next: the oldest waiting one that nothing holds, unless a returned + * card comes first. Held cards are skipped, not waited on: a queue, not a pipeline. The drain's + * pick, its consume, and send admission all read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned' || queuePauseHolding(pauses, card)) { + if (card.state === 'returned') { return null } - if (card.state === 'waiting' && card.holdReason === null) { + if (card.state === 'waiting' && card.holdReason === null && !queuePauseHolding(pauses, card)) { return card } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 7d0eb8581e66..c847ee429e66 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 @@ -59,9 +59,8 @@ export function pendingPromptExists(journal: Pick ): QueuedMessageRow | null { 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 d5aa5e497d45..161142b161fd 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 @@ -105,17 +105,15 @@ describe("a Stop's queue pause", () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { + it('a draft typed while the stopped turn winds down sends once it ends; the older one stays held', async () => { const working = await rig.workingSend() const olderId = await queuedDraft('paused by the stop') await rig.stop() const typedId = await queuedDraft('typed while stopping') await rig.settleAccepted(working, 'stopped') - await expectPaused(olderId, typedId) - expect(await rig.drafts()).toEqual([ - { messageId: olderId, state: 'waiting' }, - { messageId: typedId, state: 'waiting' } - ]) + await eventually(async () => expect(await rig.handoff(typedId)).toBeDefined()) + expect(await rig.handoff(olderId)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: olderId, state: 'waiting' }]) }) it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { 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..13a0a38a19a7 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,6 +1,6 @@ // 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 +// through the real host, the cards queued before a Stop wait, a card queued after it sends past +// 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. import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -105,7 +105,7 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) - it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { + it('a card queued after the Stop sends past the cards it holds, which stay held', async () => { const working = await rig.workingSend() const held = await queuedDraft('queued before the stop') await rig.stop() @@ -113,18 +113,13 @@ describe("Stop's event", () => { 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([ - { messageId: held, state: 'waiting' }, - { messageId: later, state: 'waiting' } - ]) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - expect(await rig.handoff(later)).toBeUndefined() - await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) - await rig.settleAccepted(await rig.handoffId(held), 'held') + // Held cards are skipped, not waited on: the newer card goes; the held one stays. await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: held, state: 'waiting' }]) + // The drain's send is the queue's own, not a person's turn: it lifts nothing. + await rig.settleAccepted(await rig.handoffId(later), 'later') + await expectHeld('stopped', held) }) it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { From f91c1aed6c78326f0bc6b082ca0222ba136f9e9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:04:01 -0700 Subject: [PATCH 05/31] fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it After a Stop, a card queued later sent past the held cards, but the queue recorded that send as Orca's own turn. It never ended the Stop's pause, so the held cards then waited forever with nothing on the card saying why. A queued card is always something the person wrote: only the client send RPC may now create one. The queue's send of it is therefore recorded as the person's turn, which ends the Stop's pause once the agent takes it, and the held cards then drain in order. --- .../journal-row-schema.ts | 6 ++-- .../queued-message-pause.test.ts | 2 +- .../queued-message-pause.ts | 16 +++++---- ...-session-claude-unproven-stop-send.test.ts | 3 +- ...tured-agent-session-codex-stop-row.test.ts | 3 +- ...structured-agent-session-host-mutations.ts | 6 ++-- ...ructured-agent-session-queued-gate.test.ts | 9 +++-- ...ured-agent-session-queued-messages.test.ts | 13 ++++++-- ...tructured-agent-session-queued-messages.ts | 13 ++++++-- ...ed-agent-session-queued-pause-lift.test.ts | 11 ++++--- ...ured-agent-session-queued-stop-row.test.ts | 33 +++++++++++-------- ...d-agent-session-stop-event-restart.test.ts | 2 +- ...tructured-agent-session-stop-event.test.ts | 3 +- .../structured-agent-session-queued-cards.ts | 2 +- ...tructured-agent-session-queued-messages.ts | 2 +- 15 files changed, 77 insertions(+), 47 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..e56108c28093 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -130,9 +130,9 @@ export type JournalSubmissionRow = JournalRowBase & { * the key and ignore it. */ queuedMessageId?: string /** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or - * a queued card they sent now); `host` for Orca's own — orchestration mail, a restart - * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it - * was recorded. Older readers keep the key and ignore it. */ + * a queued card, sent now or by the queue); `host` for Orca's own — orchestration mail, a + * restart continuation, a launch prompt. Absent on rows from before it was recorded. Older + * readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin } 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 22855e183596..bd926f70d29b 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 @@ -456,7 +456,7 @@ describe('which cards a pause holds', () => { journal.appendSubmission( { clientMessageId: `drain-${messageId}`, - origin: 'host', + origin: 'client', payloadFingerprint: `fp-${messageId}`, body: message(messageId), fence: 0, 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 e0892bf838d6..e0e413ff0475 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 @@ -7,8 +7,9 @@ // Resume has happened here since. // - 'restarted': a waiting card was written by another host process, and no person's turn has // started since this conversation opened. -// 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. +// A person's turn is an accepted submission of origin `client`: their send, or a card of theirs +// sent now or by the queue. Orchestration mail, a restart continuation and a launch prompt are +// `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' @@ -149,7 +150,8 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { // Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards // queued before it, and a steer it withdrew, wait. The drain skips held cards, so it sends ahead of -// them. true instead holds every waiting card, whenever it was queued. +// them, and that send is the person's turn, so the held cards follow it. 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 @@ -166,8 +168,8 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one that nothing holds, unless a returned - * card comes first. Held cards are skipped, not waited on: a queue, not a pipeline. The drain's - * pick, its consume, and send admission all read this. */ + * card comes first. Held cards are skipped, not waited on; a returned card blocks what follows it. + * The drain's pick, its consume, and send admission all read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] @@ -184,8 +186,8 @@ export function nextSendableQueuedCard( } /** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned - * card, which blocks everything after it until the user acts. None otherwise, so its header - * never offers a Resume that sends nothing. */ + * card, which blocks everything after it until the user acts. None otherwise, so a client that + * still shows a paused header (mobile, older desktops) never offers a Resume that sends nothing. */ export function resumableQueuePause( pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index b86e25674641..b70139b4d274 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -476,7 +476,8 @@ it('queues a follow-up as a draft by default while a message waits, and Steer re const queued = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery + delivery, + userSend: true }) expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) await commitSettled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index ef254fdc765c..0d2b3144a4f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -400,7 +400,8 @@ describe('a Codex Stop whose interrupt failed', () => { }) }, body, - delivery + delivery, + userSend: true }) if (!queued.ok || !('queued' in queued.value)) { throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf87..4b04b6a7f74f 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,9 +56,9 @@ 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 - * prompt never set it. */ + * origin, whose started turn ends a Stop's or a restart's queue pause, and the + * only send that may become a queued card. Orchestration mail, a restart + * continuation and `agent.launch`'s host-sent prompt never set it. */ userSend?: true beforeRun?: () => void } 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..c5a34298a83c 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 @@ -128,7 +128,8 @@ describe('replay preference', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -166,7 +167,8 @@ describe('replay of a deleted card', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true as const } await host.send(CALLER, params) await settleAccepted(working, 'a') @@ -209,7 +211,8 @@ describe('the hand-off link on answers', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true 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 b972c56877e4..5644860c31d9 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 @@ -79,7 +79,8 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -109,7 +110,8 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active' + delivery: 'queue-if-active', + userSend: true }) expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) expect(await drafts()).toHaveLength(0) @@ -477,6 +479,13 @@ describe('Stop and Delete', () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) + it('a host-internal send never becomes a card, even asking to queue: only a person writes cards', async () => { + await workingSend() + const mail = send('coordinator mail', 'queue-if-active', { internal: true }) + expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { const working = await workingSend() const queued = await send('conversion fails once', 'queue-if-active').result 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 c847ee429e66..30bc1d55dc6f 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 @@ -193,6 +193,7 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -200,7 +201,12 @@ export async function maybeQueueStructuredAgentSessionSend( | null > { const clientMessageId = params.envelope.clientOperationId - if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + // Only a person's send becomes a card, so the drain's send of it is their turn too. + if ( + params.delivery !== 'queue-if-active' || + !params.userSend || + !queuedMessageBodyIsTextOnly(params.body) + ) { return null } // Asked again with no ledger answer: a send this host queued answers as its replay would — @@ -347,8 +353,9 @@ export class StructuredAgentSessionQueuedMessageDrain { await journal.appendSubmission( { clientMessageId: submissionId, - // The queue's own automatic send: it never ends a pause. - origin: 'host', + // A card is only ever a person's send (§accept): sending it is their turn, which ends a + // Stop's pause so the cards it held follow. + origin: 'client', payloadFingerprint: next.fingerprint, body: next.body, fence, 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 161142b161fd..311cdcffdee2 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,5 +1,5 @@ -// 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) +// A Stop pauses the cards queued before it, derived from the journal: it lasts until a turn a +// person asked for (a send over the client RPC, or a card of theirs, sent now or by the queue) // starts — the provider accepts it, never merely the host — or they Resume. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -105,15 +105,16 @@ describe("a Stop's queue pause", () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it('a draft typed while the stopped turn winds down sends once it ends; the older one stays held', async () => { + it('a draft typed while the stopped turn winds down sends once it ends; the older one follows once that turn starts', async () => { const working = await rig.workingSend() const olderId = await queuedDraft('paused by the stop') await rig.stop() const typedId = await queuedDraft('typed while stopping') await rig.settleAccepted(working, 'stopped') await eventually(async () => expect(await rig.handoff(typedId)).toBeDefined()) - expect(await rig.handoff(olderId)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: olderId, state: 'waiting' }]) + await expectPaused(olderId) + await rig.settleAccepted(await rig.handoffId(typedId), 'typed') + await eventually(async () => expect(await rig.handoff(olderId)).toBeDefined()) }) it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { 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 13a0a38a19a7..33ebe4cc440f 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,6 +1,6 @@ // 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 past -// them, a withdrawn card comes back under it, a crash keeps it, it +// them and they follow it, 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. import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -105,21 +105,26 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) - it('a card queued after the Stop sends past the cards it holds, which stay held', async () => { + it('a card queued after the Stop sends past the cards it holds, which then follow it in order', async () => { const working = await rig.workingSend() - const held = await queuedDraft('queued before the stop') + const first = await queuedDraft('queued before the stop') + const second = await queuedDraft('also queued before the stop') await rig.stop() await rig.settleAccepted(working, 'stopped') const mail = await mailTurn() const later = await queuedDraft('queued during the mail turn') await rig.settleAccepted(mail, 'mail') - // Held cards are skipped, not waited on: the newer card goes; the held one stays. + // Held cards are skipped, not waited on: the newer card goes; the held ones wait. await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) - expect(await rig.handoff(held)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: held, state: 'waiting' }]) - // The drain's send is the queue's own, not a person's turn: it lifts nothing. + await expectHeld('stopped', first, second) + // The card is the person's, so its send is their turn: once it starts, the Stop is over. + expect(await rig.handoff(later)).toMatchObject({ origin: 'client' }) await rig.settleAccepted(await rig.handoffId(later), 'later') - await expectHeld('stopped', held) + expect(await rig.queuePause()).toBeNull() + 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()) }) it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { @@ -161,7 +166,9 @@ describe("Stop's event", () => { ['client', 'rejected'] ]) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + // Resume's drain sends it again under a fresh id, still the person's turn. + await eventually(async () => expect(await rig.handoffId(sentId)).not.toBe(handoffId)) + expect((await rig.handoff(sentId))?.origin).toBe('client') }) it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { @@ -204,7 +211,6 @@ describe("a Stop never hides a restart's pause", () => { 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() ) @@ -212,10 +218,9 @@ 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' - ]) + // That send was the person's turn, so it ended the Stop; it started before the restart, so + // the restart's pause stands. + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) await expectHeld('restarted', typed) }) }) 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 4ae9f59a015a..5e66b5a1e6f9 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 @@ -299,7 +299,7 @@ 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. + // A host send after the Stop opens its own turn. const drained = rig.send('drained after the Stop', undefined, { internal: true }) await drained.result await rig.settleAccepted(drained.id, 'drained') 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/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 26189f19851b..797721b0e965 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 @@ -19,7 +19,7 @@ import { export type QueuedMessageCardHold = | 'turn' /** Held since a Stop, restart or /clear: no caption and no promise of when it sends — not even - * after an answer. Its Steer, or any new send, releases it. */ + * after an answer. Its own Send or Steer, or any new message, releases it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' 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 844c4d1f20ce..73ff809020fe 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 @@ -54,7 +54,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - // Held cards carry no caption; each card's Steer, or any new send, releases them. + // Held cards carry no caption; each card's Send or Steer, or any new message, releases them. const queuePaused = args.queuePause !== null const cards = useMemo( From 4ef532ee2686a726a44e53a8b4fc21db0c7fcbb6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:05:34 -0700 Subject: [PATCH 06/31] fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery), so "every card is a person's" no longer holds by refusing host sends. Each card records who wrote it (the submission's client/host vocabulary) in a new nullable column; the drain records that origin, so a person's card ends a Stop's pause and Orca's does not. /clear carries the author. Rows from before the column read as a person's. The userSend-only admission gate is removed. --- .../journal-queued-messages.ts | 3 +- .../journal-row-schema.ts | 8 +- .../journal-submission-queued-link.test.ts | 6 +- ...queued-message-bookkeeping-failure.test.ts | 6 +- .../queued-message-delivered-echo.test.ts | 3 +- .../queued-message-pause.test.ts | 4 +- .../queued-message-pause.ts | 10 +- .../queued-message-schema.ts | 4 +- .../queued-message-store.test.ts | 65 ++++++++++- .../queued-message-table.ts | 24 +++- ...structured-agent-session-host-mutations.ts | 4 +- ...d-agent-session-queued-card-author.test.ts | 105 ++++++++++++++++++ ...ured-agent-session-queued-messages.test.ts | 7 -- ...tructured-agent-session-queued-messages.ts | 16 +-- ...ructured-agent-session-queued-mutations.ts | 3 +- ...ent-session-restore-without-import.test.ts | 3 +- 16 files changed, 226 insertions(+), 45 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts 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 1a787b77fe5b..57090fe632dc 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 @@ -12,7 +12,7 @@ import { } from '../../../shared/agent-session-host-authority' import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' -import type { JournalRow } from './journal-row-schema' +import type { JournalRow, JournalSubmissionOrigin } from './journal-row-schema' import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' @@ -108,6 +108,7 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string + origin: JournalSubmissionOrigin }): Promise { const { sessionId } = this.deps let inserted = false diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e56108c28093..4850b6087229 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -129,10 +129,10 @@ export type JournalSubmissionRow = JournalRowBase & { /** The queued draft this submission hands off; absent for a direct send. Older readers keep * the key and ignore it. */ queuedMessageId?: string - /** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or - * a queued card, sent now or by the queue); `host` for Orca's own — orchestration mail, a - * restart continuation, a launch prompt. Absent on rows from before it was recorded. Older - * readers keep the key and ignore it. */ + /** Who asked for this turn: `client` for a person — their send over the client send RPC, Send + * on a card, or the queue's send of a card they wrote; `host` for Orca's own — orchestration + * mail, a restart continuation, a launch prompt, or the queue's send of a card one of those + * wrote. Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index fc9431646158..654e02c5c851 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -47,7 +47,8 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission messageId: draftId, body: BODY, fingerprint: 'fp', - hostInstance: 'p' + hostInstance: 'p', + origin: 'client' }) await journal.appendSubmission( { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, @@ -96,7 +97,8 @@ describe('the submission names the queued draft it hands off', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp', - hostInstance: 'p' + hostInstance: 'p', + origin: 'client' }) await expect( journal.appendSubmission( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 638a62bdf02a..21b674f1bd41 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -64,7 +64,8 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string): messageId, body, fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin: 'client' }) await journal.appendSubmission( { @@ -172,7 +173,8 @@ describe('draft bookkeeping inside a journal append', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin: 'client' }) expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) const commit = failNextCommit() diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 4ce62c6192b2..02911d773c0d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -76,7 +76,8 @@ async function handOffAndReject( messageId: 'draft-1', body, fingerprint, - hostInstance: 'p' + hostInstance: 'p', + origin: 'client' }) await journal.appendSubmission( { 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 bd926f70d29b..ec346fbda88c 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 @@ -62,6 +62,7 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, + origin: 'client', ...(carriedFrom ? { carriedFrom } : {}) }) } @@ -496,7 +497,8 @@ describe("a restart's pause", () => { messageId: 'draft-restart', body: message('written before the restart'), fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' + hostInstance: 'proc-0', + origin: 'client' }) expect(reason(journal)).toBe('restarted') await queueDraft(journal, 'draft-legacy') 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 e0e413ff0475..0481264974be 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 @@ -7,9 +7,9 @@ // Resume has happened here since. // - 'restarted': a waiting card was written by another host process, and no person's turn has // started since this conversation opened. -// A person's turn is an accepted submission of origin `client`: their send, or a card of theirs -// sent now or by the queue. Orchestration mail, a restart continuation and a launch prompt are -// `host` and never lift it. +// A person's turn is an accepted submission of origin `client`: their send, Send on any card, or +// the queue's send of a card they wrote. Orchestration mail, a restart continuation, a launch +// prompt and the queue's send of a card one of those wrote are `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' @@ -150,8 +150,8 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { // Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards // queued before it, and a steer it withdrew, wait. The drain skips held cards, so it sends ahead of -// them, and that send is the person's turn, so the held cards follow it. true instead holds every -// waiting card, whenever it was queued. +// them; when a person wrote that card its send is their turn, so the held cards follow it. 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 diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 3f9d0122fda2..d621bbf80e8e 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -12,7 +12,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'] + ['queued_sequence', 'INTEGER'], + ['origin', 'TEXT'] ] /** @@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, + origin TEXT, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index c3910e466854..5cc8f4a8ea42 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -75,15 +75,44 @@ async function open(): Promise { return journal } -async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { +async function queueDraft( + journal: AgentSessionJournal, + messageId: string, + text = 'queued text', + origin: 'client' | 'host' = 'client' +) { return journal.queuedMessages.insert({ messageId, body: message(text), fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin }) } +/** A row as a build that knows only the columns it names writes it, the host closed around it. */ +function insertAsOlderBuild(messageId: string, position: number, origin?: string): void { + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + try { + db.prepare( + `INSERT INTO queued_messages (session_id, message_id, position, body_json, fingerprint, + created_at, host_instance, state${origin === undefined ? '' : ', origin'}) + VALUES (?, ?, ?, ?, ?, ?, 'proc-0', 'waiting'${origin === undefined ? '' : ', ?'})` + ).run( + IDENTITY.sessionId, + messageId, + position, + JSON.stringify(message(messageId)), + `fp-${messageId}`, + tick(), + ...(origin === undefined ? [] : [origin]) + ) + } finally { + db.close() + } +} + async function consumeDraft( journal: AgentSessionJournal, messageId: string, @@ -155,6 +184,38 @@ describe('draft rows', () => { expect(row.position).toBe(1) }) + it("keeps who wrote a card; one from before that was recorded reads as a person's", async () => { + const first = await open() + await queueDraft(first, 'draft-host', 'mail', 'host') + await first.close() + // An older build's insert leaves the author empty; a value no build writes is not a person's. + insertAsOlderBuild('draft-older', 2) + insertAsOlderBuild('draft-unknown', 3, 'automation') + const journal = await open() + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ + ['draft-host', 'host'], + ['draft-older', 'client'], + ['draft-unknown', 'host'] + ]) + }) + + it("heals an older build's draft table that has no author column", async () => { + const first = await open() + await queueDraft(first, 'draft-1') + await first.close() + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + db.exec('ALTER TABLE queued_messages DROP COLUMN origin') + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + await queueDraft(journal, 'draft-2', 'mail', 'host') + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ + ['draft-1', 'client'], + ['draft-2', 'host'] + ]) + }) + it('assigns monotonic positions and lists in order', async () => { const journal = await open() await queueDraft(journal, 'draft-1') diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index a2bd9c3e05ab..1c1703a6cef1 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -14,6 +14,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { rejectedDraftSettlement } from './journal-dispatch-settlement' +import type { JournalSubmissionOrigin } from './journal-row-schema' import { readStoredRejectionFact } from './journal-dispatch-reducer' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' @@ -59,10 +60,13 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null + /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. A + * row from before it was recorded reads as a person's: no Orca-internal sender queued then. */ + origin: JournalSubmissionOrigin } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, origin' export function insertQueuedMessage( db: Database.Database, @@ -74,6 +78,7 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor + origin: JournalSubmissionOrigin now: number } ): QueuedMessageRow { @@ -84,7 +89,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -95,7 +100,8 @@ export function insertQueuedMessage( input.hostInstance, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence + input.queuedAt.sequence, + input.origin ) return { sessionId: input.sessionId, @@ -113,7 +119,8 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt + queuedAt: input.queuedAt, + origin: input.origin } } @@ -295,6 +302,7 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { carried_from: string | null queued_epoch: string | null queued_sequence: number | null + origin: string | null } let body: AgentJournalMessageItem try { @@ -333,10 +341,16 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null + : null, + origin: storedOrigin(record.origin) } } +/** A row from before the column was a person's send; a value no build writes is not a person's. */ +function storedOrigin(value: string | null): JournalSubmissionOrigin { + return value === null || value === 'client' ? 'client' : 'host' +} + function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null 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 4b04b6a7f74f..d3a647d2858b 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, and the - * only send that may become a queued card. Orchestration mail, a restart + * origin, whose started turn ends a Stop's or a restart's queue pause; a card it + * queues keeps that origin for the queue's send. Orchestration mail, a restart * continuation and `agent.launch`'s host-sent prompt never set it. */ userSend?: true beforeRun?: () => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts new file mode 100644 index 000000000000..29f3fde2c94d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts @@ -0,0 +1,105 @@ +// A card records who wrote it, and the queue's send of it is that author's turn: a person's card +// ends a Stop's pause, Orca's own does not. /clear carries the author with the card. + +import { afterEach, beforeEach, expect, it } from 'vitest' +import { sendAgentTurn } from '../../runtime/orchestration/send-agent-turn' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string, options?: { internal?: true }): Promise { + const queued = await rig.send(text, 'queue-if-active', options).result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** Each stored card's id and who wrote it. */ +function cardOrigins(sessionId = SESSION): [string, string][] { + const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal + if (!journal) { + throw new Error('no open journal') + } + return journal.queuedMessages.list().map((row) => [row.messageId, row.origin]) +} + +it("a host-authored card queues, and its drained send does not end a Stop's hold; a person's card does", async () => { + const working = await rig.workingSend() + const heldId = await queuedDraft('queued before the stop') + await rig.stop() + // Both queued while the stopped turn winds down, so the Stop holds neither. + const mail = await sendAgentTurn({ + kind: 'structured-session', + host: rig.host, + sessionId: SESSION, + callerKey: 'orchestration-1', + turn: { + body: hostTestMessage('coordinator mail'), + delivery: 'queue', + operationId: hostTestOperationId(), + expectedRuntimeFence: 1 + } + }) + if (mail.kind !== 'queued') { + throw new Error('expected the mail to queue') + } + const typedId = await queuedDraft('typed while stopping') + expect(cardOrigins()).toEqual([ + [heldId, 'client'], + [mail.clientMessageId, 'host'], + [typedId, 'client'] + ]) + await rig.settleAccepted(working, 'a') + // Orca's card goes first, as Orca's turn: the Stop still holds the older card. + await eventually(async () => + expect(await rig.handoff(mail.clientMessageId)).toMatchObject({ origin: 'host' }) + ) + await rig.settleAccepted(await rig.handoffId(mail.clientMessageId), 'b') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The person's card is their turn: once it starts, the held card follows. + await eventually(async () => + expect(await rig.handoff(typedId)).toMatchObject({ origin: 'client' }) + ) + expect(await rig.handoff(heldId)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(typedId), 'c') + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) +}) + +it('a card /clear carries keeps who wrote it', async () => { + const working = await rig.workingSend() + const typedId = await queuedDraft('typed text') + const mailId = await queuedDraft('coordinator mail', { internal: true }) + await rig.stop() + await rig.settleAccepted(working, 'a') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(cardOrigins(replacementId)).toEqual([ + [typedId, 'client'], + [mailId, 'host'] + ]) +}) 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 f0114fb17d3e..3011fa194d58 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 @@ -480,13 +480,6 @@ describe('Stop and Delete', () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) - it('a host-internal send never becomes a card, even asking to queue: only a person writes cards', async () => { - await workingSend() - const mail = send('coordinator mail', 'queue-if-active', { internal: true }) - expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) - expect(await drafts()).toHaveLength(0) - }) - it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { const working = await workingSend() const queued = await send('conversion fails once', 'queue-if-active').result 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 0587bee653ac..f477514f0ae5 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 @@ -201,12 +201,7 @@ export async function maybeQueueStructuredAgentSessionSend( | null > { const clientMessageId = params.envelope.clientOperationId - // Only a person's send becomes a card, so the drain's send of it is their turn too. - if ( - params.delivery !== 'queue-if-active' || - !params.userSend || - !queuedMessageBodyIsTextOnly(params.body) - ) { + if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { return null } // Asked again with no ledger answer: a send this host queued answers as its replay would — @@ -240,7 +235,8 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() + hostInstance: structuredAgentSessionHostInstance(), + origin: params.userSend ? 'client' : 'host' }) return { ok: true, @@ -349,9 +345,9 @@ export class StructuredAgentSessionQueuedMessageDrain { await journal.appendSubmission( { clientMessageId: submissionId, - // A card is only ever a person's send (§accept): sending it is their turn, which ends a - // Stop's pause so the cards it held follow. - origin: 'client', + // Whoever wrote the card asked for this turn: a person's ends a Stop's pause, so the cards + // it held follow; Orca's own does not. + origin: next.origin, payloadFingerprint: next.fingerprint, body: next.body, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index ee32b3648a90..d3c06d9609c4 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 @@ -117,7 +117,8 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId + carriedFrom: ctx.sessionId, + origin: row.origin }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 4fa2811472a4..c8fe5d9da5d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -518,7 +518,8 @@ describe('startup restore of chats still in their per-chat files', () => { messageId: 'draft-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin: 'client' }) expect(journal.importPending).toBe(false) From 0809787d9b24bf53b9bfa7bb5e6fdd31d94950a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:18:57 -0700 Subject: [PATCH 07/31] docs(native-chat): state why an unrecorded card author reads as a person's --- .../native-chat/agent-session-journal/queued-message-table.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 1c1703a6cef1..1eb92414855e 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -60,8 +60,8 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null - /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. A - * row from before it was recorded reads as a person's: no Orca-internal sender queued then. */ + /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. + * Unrecorded reads as a person's: before it, no production caller queued an Orca send. */ origin: JournalSubmissionOrigin } From ccbe202b8f5b99c22fcf9b497159986ad37a2544 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:42:34 -0700 Subject: [PATCH 08/31] fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume A restart's pause held every waiting card, including one a person typed after the restart while Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only cards another host process wrote, the same way a Stop holds only cards queued before it. The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the existing agentSession.queuedMessagesResume, guarded against a second press in flight. A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its Steer no longer flips to Send for the frame in between. --- .../queued-message-pause.test.ts | 10 + .../queued-message-pause.ts | 37 ++-- ...ured-agent-session-queued-stop-row.test.ts | 21 +- .../native-chat/NativeChatComposer.tsx | 4 +- .../NativeChatComposerActions.test.tsx | 48 ++++ .../native-chat/NativeChatComposerActions.tsx | 15 +- .../native-chat/NativeChatComposerField.tsx | 18 +- .../NativeChatQueuedMessageList.test.tsx | 3 +- ...tiveChatStructuredSession.test-harness.tsx | 12 +- .../NativeChatStructuredSession.test.tsx | 11 + .../NativeChatStructuredSession.tsx | 1 + ...tiveChatStructuredSessionDelivery.test.tsx | 3 +- ...native-chat-composer-field-resume.test.tsx | 151 +++++++++++++ ...ative-chat-composer-primary-action.test.ts | 18 ++ .../native-chat-composer-primary-action.ts | 16 ++ .../native-chat/native-chat-composer-types.ts | 3 + ...nt-session-queued-messages.resume.test.tsx | 208 ++++++++++++++++++ ...tructured-agent-session-queued-messages.ts | 57 ++++- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 24 files changed, 613 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx 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 ec346fbda88c..fb96550c39ea 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 @@ -585,6 +585,16 @@ describe('which cards the pauses in force hold', () => { expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) }) + it('a restart holds only the cards another process wrote, never one written since', () => { + const cards = [card('before', 1, { hostInstance: DEAD }), card('since', 2)] + expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['restarted']) + expect(holding(cards, 0)).toEqual([ + ['before', 'restarted'], + ['since', null] + ]) + expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('since') + }) + it('the next card skips held ones, but never a returned card', () => { const held = card('held', 3) const after = card('after', 5) 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 0481264974be..86e7dc7019fb 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -6,7 +6,7 @@ // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or // Resume has happened here since. // - 'restarted': a waiting card was written by another host process, and no person's turn has -// started since this conversation opened. +// started since this conversation opened. It holds only those cards, never one written since. // A person's turn is an accepted submission of origin `client`: their send, Send on any card, or // the queue's send of a card they wrote. Orchestration mail, a restart continuation, a launch // prompt and the queue's send of a card one of those wrote are `host` and never lift it. @@ -23,11 +23,12 @@ export type JournalQueuePauseMarks = { resumedSequence: number } -export type DerivedQueuePause = { - reason: QueuePauseReason - /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ - since: AgentJournalCursor | null -} +export type DerivedQueuePause = + /** `since`: where the Stop was written; a card queued at or after it is newer. */ + | { reason: 'stopped'; since: AgentJournalCursor } + | { reason: 'cleared' } + /** `hostInstance`: this process; a card it wrote came after the restart. */ + | { reason: 'restarted'; hostInstance: string } type QueueCard = { state: string @@ -123,35 +124,37 @@ export function deriveQueuePauses(input: { 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) { - pauses.push({ reason: 'cleared', since: null }) + pauses.push({ reason: 'cleared' }) } if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { - // The process that wrote a card is gone: every card waits, whenever it was written. - pauses.push({ reason: 'restarted', since: null }) + pauses.push({ reason: 'restarted', hostInstance: input.hostInstance }) } return pauses } -/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a - * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build - * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ +/** Queued before the pause began: for /clear, a card it carried; for a restart, a card another + * process wrote. For a Stop, a card queued before its row; one from another epoch (before a + * rewind) or from a build that recorded no position counts as before. A withdrawn steer keeps its + * position, so is held. */ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { if (pause.reason === 'cleared') { return card.carriedFrom !== null } + if (pause.reason === 'restarted') { + return card.hostInstance !== pause.hostInstance + } const { since } = pause return ( - since === null || card.queuedAt === null || card.queuedAt.epoch !== since.epoch || card.queuedAt.sequence < since.sequence ) } -// 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. The drain skips held cards, so it sends ahead of -// them; when a person wrote that card its send is their turn, so the held cards follow it. true -// instead holds every waiting card, whenever it was queued. +// Product decision: a card queued AFTER a Stop or a restart is a new instruction and is not held; +// only cards queued before it, and a steer a Stop withdrew, wait. The drain skips held cards, so +// it sends ahead of them; when a person wrote that card its send is their turn, so the held cards +// follow it. 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 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 33ebe4cc440f..300f5eed3e74 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,8 @@ // 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 past // them and they follow it, 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. +// never hides a restart's pause, a restart's holds only cards written before it, and no stored +// pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -225,6 +226,24 @@ describe("a Stop never hides a restart's pause", () => { }) }) +describe("a restart's pause", () => { + it("holds only cards written before it: one typed during Orca's own turn sends when that turn ends, and the older card follows once it starts", async () => { + const working = await rig.workingSend() + const before = await queuedDraft('written before the restart') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + // Orca's own turn after the restart, such as its continuation. + const continuation = await mailTurn() + const typed = await queuedDraft('typed during the continuation') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await rig.settleAccepted(continuation, 'continuation') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + expect(await rig.handoff(before)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(typed), 'typed') + await eventually(async () => expect(await rig.handoff(before)).toBeDefined()) + }) +}) + describe("a /clear's carried cards", () => { function clear() { const fields = { command: 'clear' as const } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d7..2253b5a6faad 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -67,7 +67,8 @@ const NativeChatComposerPane = forwardRef { expect(onSend).not.toHaveBeenCalled() expect(onStop).not.toHaveBeenCalled() }) + + 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 3012aa28cc44..a72a20323d28 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, Mic, Plus, Square } from 'lucide-react' +import { ArrowUp, 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' @@ -25,6 +25,9 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + /** Present when no turn runs, the composer is empty and the queue is held: the primary action + * is Resume, which releases the held cards. */ + onResume?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null @@ -47,6 +50,7 @@ export function NativeChatComposerActions({ onDictationHoldEnd, onSend, onStop, + onResume, sessionOptionsSurface, sessionOptionsSnapshot, sessionOptionsPickerRequest, @@ -61,10 +65,13 @@ export function NativeChatComposerActions({ } if (isWorking) { onStop?.() + } else if (onResume) { + onResume() } else { onSend() } } + const resumes = !isWorking && onResume !== undefined const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') @@ -151,7 +158,9 @@ export function NativeChatComposerActions({ aria-label={ isWorking ? translate('components.native-chat.stop', 'Stop the agent') - : translate('components.native-chat.composer.send', 'Send') + : resumes + ? translate('components.native-chat.queuedMessages.resume', 'Resume') + : translate('components.native-chat.composer.send', 'Send') } disabled={sendDisabled} onClick={handleCriticalAction} @@ -161,6 +170,8 @@ export function NativeChatComposerActions({ > {isWorking ? ( + ) : resumes ? ( + ) : ( )} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index ccbd38afe84f..97acea292306 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -15,7 +15,11 @@ import type { SessionOptionDescriptor, SessionOptionsSurface } from '../../../../shared/native-chat-session-options' -import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' +import type { + NativeChatComposerProps, + NativeChatOptionPickerRequest +} from './native-chat-composer-types' +import { nativeChatComposerPrimaryAction } from './native-chat-composer-primary-action' import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' import type { NativeChatComposerGoalMode } from './use-native-chat-composer-submit' import { translate } from '@/i18n/i18n' @@ -56,6 +60,7 @@ export type NativeChatComposerFieldProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + queueResume?: NativeChatComposerProps['queueResume'] sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] contextUsage?: NativeChatContextUsageSummary | null @@ -130,6 +135,7 @@ export function NativeChatComposerField({ onDictationHoldEnd, onSend, onStop, + queueResume, sessionOptionsSurface, sessionOptionsSnapshot, contextUsage, @@ -168,6 +174,13 @@ export function NativeChatComposerField({ onImeSettled(element) } + const primaryAction = nativeChatComposerPrimaryAction({ + isWorking, + composerEmpty: draft.trim() === '' && imageAttachments.length === 0, + queueHeld: queueResume !== undefined + }) + const resumesQueue = primaryAction === 'resume' ? queueResume : undefined + return (
    {/* Extra bottom padding keeps the input box off the window rim. */} @@ -284,7 +297,7 @@ export function NativeChatComposerField({ {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - steerNewest: vi.fn(() => false) + steerNewest: vi.fn(() => false), + queueResume: undefined } } 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 58035cbc742e..329457096f79 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -87,6 +87,7 @@ export function createStructuredSessionMocks() { structuredTransport?: Record isWorking?: boolean onStop?: () => void + queueResume?: { resume: () => void; resuming: boolean } }>(), approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, @@ -118,7 +119,9 @@ export function createStructuredSessionMocks() { queuedSteer: vi.fn<(messageId: string) => Promise>(async () => {}), queuedRemove: vi.fn<(messageId: string) => Promise>(async () => {}), queuedEdit: vi.fn<(messageId: string) => Promise>(async () => {}), - queuedSteerNewest: vi.fn<() => boolean>(() => false) + queuedSteerNewest: vi.fn<() => boolean>(() => false), + queuedResumable: false, + queuedResume: vi.fn<() => Promise>(async () => {}) } const moduleFactories = { @@ -196,7 +199,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, @@ -333,6 +339,8 @@ export function createStructuredSessionMocks() { mocks.loadingOlder = false mocks.olderHistoryGeneration = 0 mocks.loadOlder.mockReset() + mocks.queuedResumable = false + mocks.queuedResume.mockReset() } return { mocks, moduleFactories, resetStructuredSessionMocks } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e2c4c6ff0304..1ceeeda686d1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -391,6 +391,17 @@ describe('NativeChatStructuredSession', () => { expect(mocks.cancel).not.toHaveBeenCalled() }) + it('hands the composer Resume only while the queue controller offers it', () => { + const { rerender } = render(claudeSessionView('structured-tab-resume', 'session-resume')) + expect(mocks.composerProps?.queueResume).toBeUndefined() + mocks.queuedResumable = true + rerender(claudeSessionView('structured-tab-resume', 'session-resume')) + act(() => { + mocks.composerProps?.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 eca921ed086c..20aaf1aadf55 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -396,6 +396,7 @@ export function NativeChatStructuredSession( isWorking={controller.canStop} onStop={() => void controller.stop()} steerQueued={controller.queuedMessages.steerNewest} + queueResume={controller.queuedMessages.queueResume} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 17567d984d80..cb4e3ee4cab7 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -96,7 +96,8 @@ vi.mock('./use-structured-agent-session', async () => { 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-field-resume.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx new file mode 100644 index 000000000000..a490e16bcd97 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx @@ -0,0 +1,151 @@ +// @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 = { + draft?: string + imageAttachments?: { id: string; path: string }[] + isWorking?: boolean + queueResume?: { resume: () => void; resuming: boolean } +} + +const NO_IMAGES: { id: string; path: string }[] = [] + +function TestField({ + 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('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..3020e8b5f924 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts @@ -0,0 +1,16 @@ +// 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' + +export function nativeChatComposerPrimaryAction(input: { + isWorking: boolean + composerEmpty: boolean + /** The host holds a queued card that Resume would send. */ + queueHeld: boolean +}): NativeChatComposerPrimaryAction { + if (input.isWorking) { + return 'stop' + } + return input.composerEmpty && input.queueHeld ? 'resume' : 'send' +} 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 922d16924f3f..736b7baccb2a 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 @@ -78,6 +78,9 @@ export type NativeChatComposerProps = { /** Cmd/Ctrl+Enter from an empty composer: send the newest queued draft now. * False = nothing queued, and the chord falls through to a plain send. */ steerQueued?: () => boolean + /** Present while the host holds the queue and no turn runs: an empty composer's primary + * action becomes Resume, which releases it. */ + queueResume?: { resume: () => void; resuming: boolean } | undefined } /** Launch context prefilled into the TUI input as an unsent draft, plus the two 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 new file mode 100644 index 000000000000..0f10c832763c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -0,0 +1,208 @@ +// @vitest-environment happy-dom + +// Resume releases a held queue through its own RPC, over the same fenced write every card action +// uses: offered only while no turn runs and the host holds a card it would send; a refusal or a +// failure is one toast, and Resume stays the way to try again. A card the queue is about to send +// keeps the run going across the gap between a turn's end and that send. + +import { act, 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 } } + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: ResumeParams) => Promise>(), + toastError: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' + +const RESUMED = { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { resumed: true } +} + +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 = { + queuedMessages?: AgentSessionQueuedMessage[] + queuePause?: AgentSessionQueuePause | null + isWorking?: boolean + hasPendingPrompt?: boolean +} + +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: 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(() => { + vi.clearAllMocks() +}) + +describe('whether Resume is offered', () => { + it.each(['stopped', 'restarted', '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 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 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("between a turn's end and the queue's send of its next card", () => { + it('a card nothing holds keeps the run going, so its Steer never flips to Send and back', () => { + const next = [card('next')] + const { result, rerender } = renderController({ + queuedMessages: next, + queuePause: null, + isWorking: true + }) + const running = [result.current.turnRunning] + // The turn ended; the host has not yet published the card's send. + rerender({ queuedMessages: next, queuePause: null, isWorking: false }) + running.push(result.current.turnRunning) + expect(result.current.queueResume).toBeUndefined() + rerender({ queuedMessages: [], queuePause: null, isWorking: true }) + running.push(result.current.turnRunning) + expect(running).toEqual([true, true, true]) + }) + + it('a card that waits on a hold, an answer or a returned card does not', () => { + const idle = { queuePause: null, isWorking: false } + expect(renderController({ ...idle }).result.current.turnRunning).toBe(true) + expect(renderController({ isWorking: false }).result.current.turnRunning).toBe(false) + const prompt = renderController({ ...idle, hasPendingPrompt: true }) + expect(prompt.result.current.turnRunning).toBe(false) + const returned = [card('returned', { state: 'returned' }), card('behind', { position: 2 })] + expect(renderController({ ...idle, queuedMessages: returned }).result.current.turnRunning).toBe( + false + ) + }) +}) + +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() + await act(() => resume(result)) + expect(mocks.call).toHaveBeenCalledTimes(1) + const [, method, params] = mocks.call.mock.calls[0] ?? [] + expect(method).toBe('agentSession.queuedMessagesResume') + expect(Object.keys(params ?? {})).toEqual(['envelope']) + 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() + 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, + refusal: { code: 'agent_session_conflict', message: 'The session moved on.' } + }) + mocks.call.mockRejectedValueOnce(new Error('socket closed')) + const { result } = renderController() + await act(() => resume(result)) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + 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 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(RESUMED) + const { result } = renderController() + for (let press = 0; press < 3; press += 1) { + 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) + }) +}) 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 73ff809020fe..54838ff119d6 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,16 +1,17 @@ // 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, and Resume of a held queue. +// 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 } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, AgentSessionQueuePause, AgentSessionSendResult } from '../../../../shared/agent-session-wire' @@ -24,7 +25,8 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] - /** A turn is running, so Send-now steers into it rather than starting one. */ + /** A turn is running, or the queue is about to send its next card, so Send-now steers rather + * than starting a turn. */ turnRunning: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise @@ -33,6 +35,15 @@ 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 no turn runs and the host holds a card Resume would send: the composer offers + * Resume. A failure is a toast, and Resume stays the retry. */ + queueResume: StructuredAgentSessionQueueResume | undefined +} + +export type StructuredAgentSessionQueueResume = { + resume: () => Promise + /** A Resume is in flight. */ + resuming: boolean } function alreadySentNotice(): void { @@ -54,7 +65,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - // Held cards carry no caption; each card's Send or Steer, or any new message, releases them. + // Held cards carry no caption; Resume, a card's Send or Steer, or a new message releases them. const queuePaused = args.queuePause !== null const cards = useMemo( @@ -151,5 +162,35 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, turnRunning: args.isWorking, steer, remove, edit, steerNewest } + const resumingRef = useRef(false) + const [resuming, setResuming] = useState(false) + const resume = useCallback(async (): Promise => { + if (resumingRef.current) { + return + } + resumingRef.current = true + setResuming(true) + try { + await mutate( + 'agentSession.queuedMessagesResume', + 'agentSession.queuedMessagesResume', + {} + ) + } finally { + resumingRef.current = false + setResuming(false) + } + }, [mutate]) + // A turn's end and the queue's send of its next card publish as two frames; a card nothing holds + // keeps the run going across that gap, so its Steer never flips to Send and back. + const turnRunning = args.isWorking || cards.some((card) => card.hold === 'turn') + // Only over a card the published pause holds: one held on its own, returned or behind a + // returned card would not send, so Resume would do nothing. + const resumable = enabled && !turnRunning && cards.some((card) => card.hold === 'queue-paused') + const queueResume = useMemo( + () => (resumable ? { resume, resuming } : undefined), + [resumable, resume, resuming] + ) + + return { cards, turnRunning, steer, remove, edit, steerNewest, queueResume } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 641c1e6590f7..624bcd12df52 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18071,7 +18071,8 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", - "paused": "Paused" + "paused": "Paused", + "resume": "Resume" }, "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6bbfd6ca187b..1891e56aa128 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15219,7 +15219,8 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", - "paused": "En pausa" + "paused": "En pausa", + "resume": "Reanudar" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f386bf2ff38e..ebf3b163b2aa 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17904,7 +17904,8 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", - "paused": "En pause" + "paused": "En pause", + "resume": "Reprendre" }, "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e386e9cb71f7..b6891dc4d608 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17841,7 +17841,8 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", - "paused": "一時停止中" + "paused": "一時停止中", + "resume": "再開" }, "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cbfdfb036b75..3c3960fe8023 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17841,7 +17841,8 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", - "paused": "일시 중지됨" + "paused": "일시 중지됨", + "resume": "재개" }, "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 938913983dcc..16bad88eba84 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17858,7 +17858,8 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", - "paused": "已暂停" + "paused": "已暂停", + "resume": "继续" }, "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", From bf8207791ce19516d002fe25fc7fe5e7808ab38d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:11:21 -0700 Subject: [PATCH 09/31] fix(native-chat): the host publishes which pause holds each queued card The host published one pause for the whole queue, so a client held every waiting card while it was set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the composer could flash Resume and the cards Send, and a card queued after a Stop lost its "Waiting for your answer" caption. Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from the same rule the drain reads. A client holds only those cards; against a host without the field it falls back to the queue-level pause. --- config/tsconfig.node.json | 1 + ...ed-agent-session-queued-card-holds.test.ts | 146 ++++++++++++++++++ ...ctured-agent-session-queued-publication.ts | 47 ++++-- .../NativeChatQueuedMessageCard.tsx | 10 +- ...uctured-agent-session-queued-cards.test.ts | 44 +++++- .../structured-agent-session-queued-cards.ts | 31 +++- ...tructured-agent-session-queued-messages.ts | 10 +- .../agent-session-queued-message-wire.ts | 4 + 8 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 7d4d951d2d62..20c324385790 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -11,6 +11,7 @@ "../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/preload/**/*", "../src/shared/**/*", "../src/relay/**/*", 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..41f204e6d19f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -0,0 +1,146 @@ +// The host publishes which pause holds each card, and a client reading its updates one at a time +// sees the queue's run continue across a turn's end and the queue's send of the next card: no +// card flips Steer → Send → Steer, and Resume is never offered in between. + +import { afterEach, beforeEach, describe, expect, it } 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, + queuedMessageQueueRun +} from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +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 +} + +/** Each waiting card's published hold, by id. */ +async function heldBy(): Promise> { + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return Object.fromEntries( + (page.page.queuedMessages ?? []).map((card) => [card.messageId, card.heldBy]) + ) +} + +type ClientView = { resumable: boolean; steers: boolean[]; cards: number; working: boolean } + +/** Folds every published update as a client does, one at a time, into what its queue shows. */ +async function watchClient(): Promise { + const views: ClientView[] = [] + const submissions = new Map() + const turns = new Map() + let queued: AgentSessionQueuedMessage[] = [] + let queuePause: AgentSessionQueuePause | 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 + } + const running = [...turns].find(([, state]) => state === 'running')?.[0] ?? null + const all = [...submissions.values()] + const cards = projectQueuedMessageCards(queued, all, { + hasPendingPrompt: false, + queuePaused: queuePause !== null + }) + const working = isStructuredAgentSessionMainAgentWorking(running, all) + const run = queuedMessageQueueRun(cards, working) + views.push({ + resumable: run.resumable, + steers: cards.map((card) => queuedMessageCardSteers(card, run.turnRunning)), + cards: cards.length, + working + }) + } + }) + return views +} + +describe('which pause holds each card', () => { + it("a Stop's: the card queued before it, not the one typed while it lands; Resume and Send never show as the queue sends 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') + expect(await heldBy()).toEqual({ [held]: { reason: 'stopped' }, [typed]: null }) + const views = await watchClient() + await rig.settleAccepted(working, 'stopped') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(typed), 'typed') + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + // The update between the stopped turn's end and the typed card's send is among them. + expect(views).toContainEqual({ + resumable: false, + steers: [true, true], + cards: 2, + working: false + }) + expect(views.filter((view) => view.resumable)).toEqual([]) + expect(views.flatMap((view) => view.steers)).not.toContain(false) + }) + + it("a restart's: the card from before it, not one typed during Orca's own turn since; the same holds as that turn ends", async () => { + const working = await rig.workingSend() + const before = await queuedDraft('written before the restart') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + const continuation = rig.send('continue where you left off', undefined, { internal: true }) + await continuation.result + await eventually(async () => + expect((await rig.submission(continuation.id))?.handedOverAt).toBeDefined() + ) + const typed = await queuedDraft('typed during the continuation') + expect(await heldBy()).toEqual({ [before]: { reason: 'restarted' }, [typed]: null }) + const views = await watchClient() + await rig.settleAccepted(continuation.id, 'continuation') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + expect(views).toContainEqual({ + resumable: false, + steers: [true, true], + cards: 2, + working: false + }) + expect(views.filter((view) => view.resumable)).toEqual([]) + expect(views.flatMap((view) => view.steers)).not.toContain(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 1fa46616da18..e8bcf84f8fa5 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 @@ -9,7 +9,11 @@ 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 { + queuePauseHolding, + resumableQueuePause, + type DerivedQueuePause +} from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' export type QueuePublication = { @@ -18,8 +22,11 @@ export type QueuePublication = { } /** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion); a Stop or a restart pauses the queue, published once beside it. */ -function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + * conversion); `heldBy` names the queue-level pause holding the card, if any. */ +function computePublishedQueuedMessages( + journal: AgentSessionJournal, + pauses: readonly DerivedQueuePause[] +): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { if (row.state !== 'waiting' && row.state !== 'returned') { @@ -36,6 +43,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } : {}), + heldBy: heldBy(pauses, row), ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), ...(row.state === 'returned' && row.returnedRejection ? { returnedRejection: row.returnedRejection } @@ -45,21 +53,33 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess return published } +function heldBy( + pauses: readonly DerivedQueuePause[], + row: Parameters[1] +): AgentSessionQueuePause | null { + const holding = queuePauseHolding(pauses, row) + return holding ? { reason: holding.reason } : null +} + type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } /** Reference-stable per journal handle: an unchanged list is never - * re-serialized onto token-stream frames, and any draft-table write changes - * the reference by construction. */ + * re-serialized onto token-stream frames, and any draft-table write or change + * of the pauses in force changes the reference by construction. */ const listMemos = new WeakMap() const publications = new WeakMap() -function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { - const key = String(journal.queuedMessages.revision()) +function readPublishedQueuedMessages( + journal: AgentSessionJournal, + pauses: readonly DerivedQueuePause[] +): AgentSessionQueuedMessage[] { + // The pauses also turn on journal rows (a Stop, a person's turn), not only on the drafts. + const key = `${journal.queuedMessages.revision()}:${JSON.stringify(pauses)}` const memo = listMemos.get(journal) if (memo && memo.key === key) { return memo.list } - const list = computePublishedQueuedMessages(journal) + const list = computePublishedQueuedMessages(journal, pauses) // Belt for the identity dedup: equal recomputed content keeps the previous reference. const serialized = JSON.stringify(list) if (memo && memo.serialized === serialized) { @@ -80,11 +100,12 @@ export function sameQueuePause( } export function readQueuePublication(journal: AgentSessionJournal): 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()) + // Read per emit: the pauses also turn on submissions (a person's turn starting). + const pauses = structuredQueuePauses(journal) + const queuedMessages = readPublishedQueuedMessages(journal, pauses) + // Shown only over a card Resume would send, so a header never offers to send nothing; + // deleting a blocking returned card shows it again. + const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index f4872c08120f..cc4f2f173f93 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -23,7 +23,10 @@ import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-sessi import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' 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' /** The visible caption under the text; the default waiting hold needs none. */ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { @@ -78,8 +81,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null } } -/** Steer names the jump into a running turn; with no turn running, or for a card held on its own - * or returned, the action and tooltip are plainly Send. */ +/** Steer's or Send's label and tooltip (`queuedMessageCardSteers`). */ export function queuedMessageCardSendNow( card: QueuedMessageCard, turnRunning: boolean @@ -89,7 +91,7 @@ export function queuedMessageCardSendNow( label: string hint: string } { - if (!turnRunning || card.hold === 'paused' || card.hold === 'returned') { + if (!queuedMessageCardSteers(card, turnRunning)) { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 995d80247699..d9ef924ebaae 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, + queuedMessageQueueRun } from './structured-agent-session-queued-cards' function draft( @@ -159,6 +161,44 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) + it("a host that names each card's hold holds only those cards; one that does not holds every card", () => { + const named = [ + draft('held', 1, { heldBy: { reason: 'stopped' } }), + draft('typed-after', 2, { heldBy: null }) + ] + const paused = { hasPendingPrompt: true, queuePaused: true } + // The card queued after the Stop still says it waits for the answer. + expect(projectQueuedMessageCards(named, [], paused).map((card) => card.hold)).toEqual([ + 'queue-paused', + 'awaiting-answer' + ]) + const older = [draft('held', 1), draft('typed-after', 2)] + expect(projectQueuedMessageCards(older, [], paused).map((card) => card.hold)).toEqual([ + 'queue-paused', + 'queue-paused' + ]) + }) + + it('a card nothing holds keeps the run going; Resume only over a card a pause holds', () => { + const project = (messages: AgentSessionQueuedMessage[]) => + projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused: true }) + const held = draft('held', 1, { heldBy: { reason: 'stopped' } }) + const next = draft('next', 2, { heldBy: null }) + // Between a turn's end and the queue's send of `next`: still running, and nothing to resume. + const gap = project([held, next]) + expect(queuedMessageQueueRun(gap, false)).toEqual({ turnRunning: true, resumable: false }) + expect(gap.map((card) => queuedMessageCardSteers(card, true))).toEqual([true, true]) + expect(queuedMessageQueueRun(project([held]), false)).toEqual({ + turnRunning: false, + resumable: true + }) + const failed = draft('failed', 1, { paused: true, pausedReason: 'send_failed', heldBy: null }) + expect(queuedMessageQueueRun(project([failed]), false)).toEqual({ + turnRunning: false, + resumable: false + }) + }) + it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index 797721b0e965..4ec66daabb57 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 @@ -18,8 +18,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** Held since a Stop, restart or /clear: no caption and no promise of when it sends — not even - * after an answer. Its own Send or Steer, or any new message, releases it. */ + /** Held by a Stop, restart or /clear: no caption and no promise of when it sends — not even + * after an answer. Resume, its own Send or Steer, or any new message releases it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' @@ -63,6 +63,9 @@ export function projectQueuedMessageCards( .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) let behindReturned = false return ordered.map((message) => { + // A host that predates `heldBy` publishes only the queue's pause, which then covers every card. + const heldByPause = + message.heldBy === undefined ? session.queuePaused === true : message.heldBy !== null const hold: QueuedMessageCardHold = message.state === 'returned' ? 'returned' @@ -70,7 +73,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : session.queuePaused + : heldByPause ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' @@ -91,6 +94,28 @@ export function projectQueuedMessageCards( }) } +/** Whether the queue's run is going, and whether Resume would release anything. A turn's end and + * the queue's send of its next card reach the client as two updates; a card nothing holds keeps + * the run going across that gap, so no card flips Steer → Send → Steer and Resume never flashes. + * Resume needs a card a queue-level pause holds: one held on its own, returned or behind a + * returned card would not send. */ +export function queuedMessageQueueRun( + cards: readonly QueuedMessageCard[], + isWorking: boolean +): { turnRunning: boolean; resumable: boolean } { + const turnRunning = isWorking || cards.some((card) => card.hold === 'turn') + return { + turnRunning, + resumable: !turnRunning && cards.some((card) => card.hold === 'queue-paused') + } +} + +/** Steer jumps into a running turn; with none running, or for a card held on its own or + * returned, the action is plainly Send. */ +export function queuedMessageCardSteers(card: QueuedMessageCard, turnRunning: boolean): boolean { + return turnRunning && 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-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 54838ff119d6..111892e25a0b 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 @@ -19,6 +19,7 @@ import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + queuedMessageQueueRun, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -181,12 +182,9 @@ export function useStructuredAgentSessionQueuedMessages(args: { setResuming(false) } }, [mutate]) - // A turn's end and the queue's send of its next card publish as two frames; a card nothing holds - // keeps the run going across that gap, so its Steer never flips to Send and back. - const turnRunning = args.isWorking || cards.some((card) => card.hold === 'turn') - // Only over a card the published pause holds: one held on its own, returned or behind a - // returned card would not send, so Resume would do nothing. - const resumable = enabled && !turnRunning && cards.some((card) => card.hold === 'queue-paused') + const run = queuedMessageQueueRun(cards, args.isWorking) + const { turnRunning } = run + const resumable = enabled && run.resumable const queueResume = useMemo( () => (resumable ? { resume, resuming } : undefined), [resumable, resume, resuming] diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index f7659c95873b..a07c9e1a56e2 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -37,6 +37,10 @@ export type AgentSessionQueuedMessage = { * marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason + /** The queue-level pause holding this card, or null when none does: a card queued after a Stop + * is not held by it. Absent from a host that predates it; a client then treats every waiting + * card as held while `queuePause` is set. */ + heldBy?: AgentSessionQueuePause | null /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host * wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g. From 71d7c62632b26d30536d86f3747d3d60c1f9d4b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:18:56 -0700 Subject: [PATCH 10/31] test(native-chat): Resume needs the queue capability and is disabled whenever Send is --- .../native-chat-composer-field-resume.test.tsx | 15 +++++++++++++-- ...-agent-session-queued-messages.resume.test.tsx | 7 ++++++- 2 files changed, 19 insertions(+), 3 deletions(-) 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 index a490e16bcd97..f17d530710f9 100644 --- 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 @@ -49,6 +49,8 @@ import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-even afterEach(() => cleanup()) type FieldInput = { + /** The composer cannot send at all. */ + disabled?: boolean draft?: string imageAttachments?: { id: string; path: string }[] isWorking?: boolean @@ -58,6 +60,7 @@ type FieldInput = { const NO_IMAGES: { id: string; path: string }[] = [] function TestField({ + disabled = false, draft = '', imageAttachments = NO_IMAGES, isWorking = false, @@ -71,14 +74,14 @@ function TestField({ composerScopeKey="pane-test" textareaRef={textareaRef} draft={draft} - disabled={false} + disabled={disabled} hasPty canSend autocomplete={{ mode: 'none' }} activeSuggestion={0} notice={null} imageAttachments={imageAttachments} - sendButtonDisabled={!isWorking && draft === '' && imageAttachments.length === 0} + sendButtonDisabled={disabled || (!isWorking && draft === '' && imageAttachments.length === 0)} isWorking={isWorking} attachDisabled={false} dictationDisabled={false} @@ -141,6 +144,14 @@ describe('the composer primary button over a held queue', () => { expect(primaryButton({ ...input, queueResume: held() }).getAttribute('aria-label')).toBe(label) }) + 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() 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 0f10c832763c..f13a8e8cab63 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 @@ -44,6 +44,7 @@ function card( } type ControllerInput = { + enabled?: boolean queuedMessages?: AgentSessionQueuedMessage[] queuePause?: AgentSessionQueuePause | null isWorking?: boolean @@ -60,7 +61,7 @@ function renderController(initialProps: ControllerInput = {}) { stateRef }) return useStructuredAgentSessionQueuedMessages({ - enabled: true, + enabled: input.enabled ?? true, queuedMessages: input.queuedMessages ?? [card('held')], queuePause: input.queuePause === undefined ? { reason: 'stopped' } : input.queuePause, submissions: [], @@ -98,6 +99,10 @@ describe('whether Resume is offered', () => { } ) + 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() From 4c3a31d45cee3d8ede2b4b8c6156669b053d6c37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:23:48 -0700 Subject: [PATCH 11/31] docs(native-chat): describe per-card holds in the queue contract and table comments --- .../agent-session-journal/queued-message-table.ts | 5 +++-- src/shared/protocol-version.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 1eb92414855e..40ff205cad11 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -21,8 +21,9 @@ export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdr /** Why ONE waiting draft is held from auto-sending: its conversion failed. * Stored on the row, so it survives handle eviction and restart; a wire marker - * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue - * instead. A reader treats an unknown stored value as a plain hold. */ + * (it publishes as `pausedReason`). A Stop, restart or /clear instead holds the + * cards written before it, derived, and the queue skips held cards. A reader + * treats an unknown stored value as a plain hold. */ export type QueuedMessageHoldReason = 'send_failed' /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81d..b085b6d13f54 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -203,8 +203,9 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = // v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and // never compares a draft id with a submission id. It also publishes the queue's pause once, as // `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next -// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant -// gates the rollout. +// turn. Each card may carry `heldBy`, the pause holding it or null; when it is absent (an older +// host) a client falls back to `queuePause` for every card. A card's own hold (`paused`) means only +// that its conversion failed. The host mechanism lands first; the constant gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific From 34d5ba965bdc42b5ca024f2e728bd100964d17a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:46:54 -0700 Subject: [PATCH 12/31] fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus After Resume, the host lifts the hold in one update and sends the first card in a later one. In between nothing was running, so the composer's button flashed a disabled Send. A card nothing holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown, read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip between queued turns. Resume disables the button, which dropped keyboard focus; focus now returns to the composer. --- config/tsconfig.node.json | 1 + ...ed-agent-session-queued-card-holds.test.ts | 43 +++++++++++-- .../native-chat/NativeChatComposer.tsx | 4 +- .../NativeChatComposerActions.test.tsx | 4 ++ .../native-chat/NativeChatComposerActions.tsx | 24 ++++---- .../native-chat/NativeChatComposerField.tsx | 32 ++++++---- .../NativeChatQueuedMessageList.test.tsx | 3 +- ...tiveChatStructuredSession.test-harness.tsx | 7 ++- .../NativeChatStructuredSession.test.tsx | 7 ++- .../NativeChatStructuredSession.tsx | 4 +- ...tiveChatStructuredSessionDelivery.test.tsx | 2 +- .../StructuredAgentSessionStatusBridge.tsx | 14 ++--- ...native-chat-composer-field-resume.test.tsx | 39 ++++++++---- ...ative-chat-composer-primary-action.test.ts | 19 +++--- .../native-chat-composer-primary-action.ts | 43 +++++++++++-- .../native-chat/native-chat-composer-types.ts | 6 +- ...uctured-agent-session-queued-cards.test.ts | 23 ++++++- .../structured-agent-session-queued-cards.ts | 14 +++-- ...nt-session-queued-messages.resume.test.tsx | 60 +++++++++++++++---- ...red-agent-session-queued-messages.test.tsx | 1 + ...tructured-agent-session-queued-messages.ts | 38 +++++++----- ...e-structured-agent-session-status-field.ts | 36 +++++++++++ .../use-structured-agent-session.ts | 4 ++ 23 files changed, 322 insertions(+), 106 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-status-field.ts diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 20c324385790..7c9634b680d0 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -12,6 +12,7 @@ "../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/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 index 41f204e6d19f..db8ac155baec 100644 --- 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 @@ -14,6 +14,10 @@ import { queuedMessageCardSteers, queuedMessageQueueRun } 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 { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, @@ -48,7 +52,14 @@ async function heldBy(): Promise { @@ -84,12 +95,14 @@ async function watchClient(): Promise { queuePaused: queuePause !== null }) const working = isStructuredAgentSessionMainAgentWorking(running, all) - const run = queuedMessageQueueRun(cards, working) + const run = queuedMessageQueueRun(cards, { isWorking: working, sendBlocked: false }) + const queue = run.resumable ? 'held' : run.sendsNext ? 'sending' : null views.push({ resumable: run.resumable, steers: cards.map((card) => queuedMessageCardSteers(card, run.turnRunning)), cards: cards.length, - working + working, + button: nativeChatComposerPrimaryAction({ isWorking: working, composerEmpty: true, queue }) }) } }) @@ -113,10 +126,12 @@ describe('which pause holds each card', () => { resumable: false, steers: [true, true], cards: 2, - working: false + working: false, + button: 'stop' }) expect(views.filter((view) => view.resumable)).toEqual([]) expect(views.flatMap((view) => view.steers)).not.toContain(false) + expect(views.map((view) => view.button)).not.toContain('send') }) it("a restart's: the card from before it, not one typed during Orca's own turn since; the same holds as that turn ends", async () => { @@ -138,9 +153,27 @@ describe('which pause holds each card', () => { resumable: false, steers: [true, true], cards: 2, - working: false + working: false, + button: 'stop' }) expect(views.filter((view) => view.resumable)).toEqual([]) expect(views.flatMap((view) => view.steers)).not.toContain(false) + expect(views.map((view) => view.button)).not.toContain('send') + }) + + it('after Resume over held cards the empty composer goes from Resume straight to Stop, never Send', 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() + expect(views.at(-1)?.button).toBe('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)?.working).toBe(true)) + // The update between the lifted hold and the first card's send is among them. + expect(views).toContainEqual(expect.objectContaining({ working: false, button: 'stop' })) + expect(views.map((view) => view.button)).not.toContain('send') }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 2253b5a6faad..d5d42ea919aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -68,7 +68,7 @@ const NativeChatComposerPane = forwardRef { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="send" isWorking={false} isDictating={false} isDictationHoldMode={false} @@ -66,6 +67,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="stop" isWorking isDictating={false} isDictationHoldMode={false} @@ -95,6 +97,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="stop" isWorking isDictating={false} isDictationHoldMode={false} @@ -123,6 +126,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction={isWorking ? 'stop' : onResume ? 'resume' : 'send'} isWorking={isWorking} isDictating={false} isDictationHoldMode={false} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index a72a20323d28..e5c5fb336aa1 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -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 isWorking: boolean isDictating: boolean isDictationHoldMode: boolean @@ -25,8 +28,7 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void - /** Present when no turn runs, the composer is empty and the queue is held: the primary action - * is Resume, which releases the held cards. */ + /** Releases the held queue when the primary action is Resume. */ onResume?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] @@ -41,6 +43,7 @@ export function NativeChatComposerActions({ attachDisabled, dictationDisabled, sendDisabled, + primaryAction, isWorking, isDictating, isDictationHoldMode, @@ -57,21 +60,22 @@ export function NativeChatComposerActions({ 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 (onResume) { - onResume() + } else if (resumes) { + onResume?.() } else { onSend() } } - const resumes = !isWorking && onResume !== undefined const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') @@ -154,9 +158,9 @@ export function NativeChatComposerActions({
+ 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..11ad56ff6508 --- /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 cc4f2f173f93..1513b301b38e 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -55,8 +55,8 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) } case 'paused': - // A card's own hold. Markers localize; an absent or unknown one (newer host) is a plain - // pause, never shown raw. + // A card's own hold; the queue's pause is the list's header. Markers localize, and an absent + // or unknown one (newer host) is a plain pause, never shown raw. if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return translate( 'components.native-chat.queuedMessages.pausedSendFailed', @@ -76,22 +76,19 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) case 'turn': case 'queue-paused': - // Plainly queued: a held queue shows no caption. + // Plainly queued; a paused queue's header row carries the why. return null } } /** Steer's or Send's label and tooltip (`queuedMessageCardSteers`). */ -export function queuedMessageCardSendNow( - card: QueuedMessageCard, - turnRunning: boolean -): { +export function queuedMessageCardSendNow(card: QueuedMessageCard): { /** Steer's ↳, or Send's paper plane. */ steers: boolean label: string hint: string } { - if (!queuedMessageCardSteers(card, turnRunning)) { + if (!queuedMessageCardSteers(card)) { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), @@ -110,7 +107,6 @@ export function queuedMessageCardSendNow( export function NativeChatQueuedMessageCard({ card, - turnRunning, showsSteerShortcut, onSteer, onDelete, @@ -118,7 +114,6 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard - turnRunning: boolean /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean onSteer: () => void @@ -128,7 +123,7 @@ export function NativeChatQueuedMessageCard({ }): React.JSX.Element { const caption = queuedMessageCardCaption(card) const returned = card.state === 'returned' - const sendNow = queuedMessageCardSendNow(card, turnRunning) + const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() return (
  • & { messageId: string }): Qu } } -/** `turnRunning` defaults on: a card waiting on the turn implies one is running. */ function controller( cards: QueuedMessageCard[], - turnRunning = true + pause: { reason: string } | null = null ): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType edit: ReturnType + resume: ReturnType } { return { cards, - turnRunning, + pause, + resume: vi.fn(async () => {}), + resuming: false, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), steerNewest: vi.fn(() => false), - queueResume: undefined + 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, a restart 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() }) @@ -240,56 +286,97 @@ describe('NativeChatQueuedMessageList', () => { ) }) - it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( - "a queue the host holds ('%s') shows no header and no Resume; each card's Send sends it", - (reason) => { + it.each([ + ['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'] + ])( + "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 queuePause = { reason } as AgentSessionQueuePause - const mutate = vi.fn(async (..._call: [string, string, Record]) => null) - const waiting: AgentSessionQueuedMessage = { - messageId: 'held', - position: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'held text' }] }, - state: 'waiting' - } - function HeldQueue(): React.JSX.Element { - const owner = useStructuredAgentSessionQueuedMessages({ - enabled: true, - queuedMessages: [waiting], - queuePause, - submissions: [], - hasPendingPrompt: false, - // Nothing runs after a Stop, a restart or a /clear. - 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 { container } = render( - - - + const pause = { reason } as AgentSessionQueuePause + const { container, mutate } = renderHeldQueue( + [waitingDraft('held', 1, { heldBy: pause })], + pause ) - expect(container.textContent).not.toContain('Queue paused') - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + expect(container.textContent).toContain(text) const list = screen.getByRole('list') - // The box holds only the list: no row above the cards. - expect(list.parentElement?.children).toHaveLength(1) + // The row sits above the list, not inside it: it is not a queued message. + expect(within(list).queryByText(text)).toBeNull() const row = within(list).getByRole('listitem') - // The text is the card's only line: no caption under it. + // 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: 'Steer' })).toBeNull() - fireEvent.click(within(row).getByRole('button', { name: 'Send' })) + expect(within(row).queryByRole('button', { name: 'Send' })).toBeNull() + expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Resume' })) expect(mutate).toHaveBeenCalledWith( - 'agentSession.queuedMessageSend', - 'agentSession.queuedMessageSend', - { messageId: 'held' } + 'agentSession.queuedMessagesResume', + 'agentSession.queuedMessagesResume', + {} ) } ) + it('an older host that names no per-card hold: its queue pause holds every card and heads them', () => { + 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', () => { + renderHeldQueue([waitingDraft('typed-after', 1, { heldBy: null })], { reason: 'stopped' }) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + cleanup() + 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', () => { + const stopped = { reason: 'stopped' } as const + const { container } = renderHeldQueue( + [ + waitingDraft('refused', 1, { state: 'returned', returnedReason: null, heldBy: null }), + waitingDraft('behind', 2, { heldBy: stopped }), + waitingDraft('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: stopped }) + ], + 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 () => { + const focusComposer = vi.fn() + const owner = { + ...controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason: 'stopped' }), + resuming: true + } + render( + + + + ) + const resume = screen.getByRole('button', { name: 'Resume' }) + expect(resume).toHaveProperty('disabled', true) + expect(screen.getByTitle('Queue paused because you interrupted')).toBeTruthy() + cleanup() + const idle = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { + reason: 'stopped' + }) + render( + + + + ) + const enabled = screen.getByRole('button', { name: 'Resume' }) + enabled.focus() + fireEvent.click(enabled) + expect(idle.resume).toHaveBeenCalledTimes(1) + await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) + }) + it('Steer carries the ↳ icon; a card leads with the queue glyph, a failed one with the alert', () => { const { container } = renderList( controller([ @@ -311,20 +398,26 @@ describe('NativeChatQueuedMessageList', () => { expect(container.querySelectorAll('.lucide-list-end')).toHaveLength(1) }) - it('one bordered box holds every card, rows divided, cards unbordered', () => { + it('one bordered box holds the pause row and every card, rows divided, cards unbordered', () => { renderList( - controller([ - card({ messageId: 'waiting', hold: 'queue-paused' }), - card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), - card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) - ]) + controller( + [ + card({ messageId: 'waiting', hold: 'queue-paused' }), + card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) + ], + { reason: 'stopped' } + ) ) const list = screen.getByRole('list') const box = list.parentElement! expect(box.className.split(' ')).toEqual( - expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card']) + expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card', 'divide-y']) ) - expect(box.children).toHaveLength(1) + // The pause row is the box's first row, outside the list; the list is its second. + expect(box.children).toHaveLength(2) + expect(box.firstElementChild?.textContent).toContain('Queue paused because you interrupted') + expect(box.lastElementChild).toBe(list) expect(list.className.split(' ')).toContain('divide-y') const rows = within(list).getAllByRole('listitem') expect(rows).toHaveLength(3) @@ -336,7 +429,7 @@ describe('NativeChatQueuedMessageList', () => { expect(rows[2]?.textContent).toContain("Couldn't send — press Send to retry.") }) - it('the box holds only the list, one card or many', () => { + it('without a pause the box holds only the list, one card or many', () => { for (const count of [1, 4]) { const view = renderList( controller( @@ -362,33 +455,34 @@ describe('NativeChatQueuedMessageList', () => { expect(group.children).toHaveLength(2) }) - it('a held card keeps Send, Delete and More actions; Steer only while a turn runs', () => { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], false) + it('cards keep Steer, Delete and More actions while the queue is paused', () => { + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { + reason: 'stopped' + }) renderList(owner) const row = screen.getByRole('listitem') - expect(within(row).queryByRole('button', { name: 'Steer' })).toBeNull() + expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() expect(within(row).getByRole('button', { name: 'Delete' })).toBeTruthy() expect(within(row).getByRole('button', { name: 'More actions' })).toBeTruthy() expect(row.querySelectorAll('p')).toHaveLength(1) - const send = within(row).getByRole('button', { name: 'Send' }) - expect(send.querySelector('.lucide-send')).not.toBeNull() - fireEvent.click(send) + fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) expect(owner.steer).toHaveBeenCalledWith('waiting') - cleanup() - // A turn Orca started (mail, a restart's continuation) runs while the queue stays held. - renderList(controller([card({ messageId: 'waiting', hold: 'queue-paused' })], true)) - expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy() }) - it('Steer only while a turn runs, and never for a card held on its own or returned', () => { - const SEND = { steers: false, label: 'Send', hint: 'Send this message now' } - const STEER = { steers: true, label: 'Steer', hint: 'Submit without interrupting the model' } + 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 }), true)).toEqual(STEER) - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), false)).toEqual(SEND) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ + steers: true, + label: 'Steer', + hint: 'Submit without interrupting the model' + }) } for (const hold of ['paused', 'returned'] as const) { - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), true)).toEqual(SEND) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ + steers: false, + label: 'Send', + hint: 'Send this message now' + }) } }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index cd37aeee322d..c9d3c2f1d1b4 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -1,4 +1,6 @@ import { useRef } from 'react' +import { Pause, Play } from 'lucide-react' +import { Button } from '@/components/ui/button' import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' @@ -19,9 +21,10 @@ 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 when focus was on the queue — never pull it from wherever the user moved on to. + // 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 => { void action.then(() => { const active = document.activeElement @@ -34,7 +37,15 @@ export function NativeChatQueuedMessageList({
    {cards.length > 0 ? (
    + {/* One box: the pause row, when shown, is its first row, and each card a row below it. */}
    + {pause ? ( + refocusAfter(controller.resume())} + /> + ) : null}
      refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} @@ -61,3 +71,50 @@ export function NativeChatQueuedMessageList({
    ) } + +/** Why the queue sends nothing on its own. An unknown reason (a newer host) is a plain pause. */ +function queuePauseText(pause: { reason: string }): string { + switch (pause.reason) { + case 'stopped': + return translate( + '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', + 'Queue paused after you cleared the conversation' + ) + default: + return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') + } +} + +function NativeChatQueuePauseRow({ + pause, + resuming, + onResume +}: { + pause: { reason: string } + resuming: boolean + onResume: () => void +}): React.JSX.Element { + const text = queuePauseText(pause) + return ( +
    + +

    + {text} +

    + +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 85e1804e5a69..7f011ebb639d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -240,7 +240,8 @@ 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: controller.queuedMessages.queueHold } }, [ controller, 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 abbe42ab9f67..c8da1870ce3f 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 @@ -16,6 +16,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: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => boolean @@ -37,6 +45,8 @@ 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 } 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..1262cb878a39 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx @@ -0,0 +1,248 @@ +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)) + }) + + 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() + }) +}) 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 8a51296f8e0a..9c60e50837ad 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 @@ -11,6 +11,7 @@ import { outboxOutsideQueuedCards, projectQueuedMessageCards, queuedMessageCardSteers, + queuedMessagesQueuePause, queuedMessagesResumable } from './structured-agent-session-queued-cards' @@ -148,7 +149,7 @@ describe('queued message cards', () => { draft('behind', 4) ], [], - { hasPendingPrompt: true, queuePaused: true } + { hasPendingPrompt: true, queuePause: { reason: 'stopped' } } ) expect(cards.map((card) => card.hold)).toEqual([ 'queue-paused', @@ -166,7 +167,7 @@ describe('queued message cards', () => { draft('held', 1, { heldBy: { reason: 'stopped' } }), draft('typed-after', 2, { heldBy: null }) ] - const paused = { hasPendingPrompt: true, queuePaused: true } + const paused = { hasPendingPrompt: true, queuePause: { reason: 'restarted' } } as const // The card queued after the Stop still says it waits for the answer. expect(projectQueuedMessageCards(named, [], paused).map((card) => card.hold)).toEqual([ 'queue-paused', @@ -181,7 +182,10 @@ describe('queued message cards', () => { it('Resume only over a card a pause holds, and only while nothing runs', () => { const project = (messages: AgentSessionQueuedMessage[]) => - projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused: true }) + projectQueuedMessageCards(messages, [], { + hasPendingPrompt: false, + queuePause: { reason: 'stopped' } + }) const held = draft('held', 1, { heldBy: { reason: 'stopped' } }) expect(queuedMessagesResumable(project([held]), false)).toBe(true) // Working counts the queue's coming send, which the host names. @@ -193,7 +197,34 @@ describe('queued message cards', () => { 'turn' ]) const [card] = project([held]) - expect(card && queuedMessageCardSteers(card, true)).toBe(true) + // Still Steer while nothing runs: the header row, not the card, says it waits. + expect(card && queuedMessageCardSteers(card)).toBe(true) + }) + + it("the header names the oldest held card's pause, and none over cards Resume would not send", () => { + const project = (messages: AgentSessionQueuedMessage[]) => + projectQueuedMessageCards(messages, [], { + hasPendingPrompt: false, + queuePause: { reason: 'stopped' } + }) + const restarted = draft('restarted', 1, { heldBy: { reason: 'restarted' } }) + const stopped = draft('stopped', 2, { heldBy: { reason: 'stopped' } }) + expect(queuedMessagesQueuePause(project([restarted, stopped]))).toEqual({ reason: 'restarted' }) + // An older host names no per-card hold: its queue-level pause is the reason. + expect(queuedMessagesQueuePause(project([draft('older', 1)]))).toEqual({ reason: 'stopped' }) + const unsendable = [ + draft('returned', 1, { state: 'returned', returnedReason: null, heldBy: null }), + draft('behind', 2, { heldBy: { reason: 'stopped' } }), + draft('failed', 3, { + paused: true, + pausedReason: 'send_failed', + heldBy: { reason: 'stopped' } + }) + ] + expect(queuedMessagesQueuePause(project(unsendable))).toBeNull() + expect( + queuedMessagesQueuePause(project([draft('typed-after', 1, { heldBy: null })])) + ).toBeNull() }) it('steers the newest card', () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index 3c5861da148f..ff08241d3602 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 { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' import { structuredAgentSessionEntryAsksToQueue, @@ -18,8 +21,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** Held by a Stop, restart or /clear: no caption and no promise of when it sends — not even - * after an answer. Resume, its own Send or Steer, or any new message releases it. */ + /** Held by a Stop, restart or /clear: the header row above the cards says why and offers + * Resume, so the card makes no promise of when it sends — not even after an answer. */ | 'queue-paused' | 'awaiting-answer' | 'paused' @@ -33,6 +36,9 @@ export type QueuedMessageCard = { text: string state: 'waiting' | 'returned' hold: QueuedMessageCardHold + /** The pause holding a 'queue-paused' card. A string: a newer host may name a reason this build + * does not know. */ + queuePause?: { reason: string } pausedReason?: string returnedReason?: string | null /** The typed fact the returned card's submission settled with; read like its `rejection`. */ @@ -53,7 +59,7 @@ function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string export function projectQueuedMessageCards( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, submissions: readonly AgentJournalSubmission[], - session: { hasPendingPrompt: boolean; queuePaused?: boolean } + session: { hasPendingPrompt: boolean; queuePause?: AgentSessionQueuePause | null } ): QueuedMessageCard[] { const handedOff = handedOffQueuedMessageIds( submissions.filter((submission) => submission.dispatchState !== 'rejected') @@ -64,8 +70,7 @@ export function projectQueuedMessageCards( let behindReturned = false return ordered.map((message) => { // A host that predates `heldBy` publishes only the queue's pause, which then covers every card. - const heldByPause = - message.heldBy === undefined ? session.queuePaused === true : message.heldBy !== null + const heldBy = message.heldBy === undefined ? (session.queuePause ?? null) : message.heldBy const hold: QueuedMessageCardHold = message.state === 'returned' ? 'returned' @@ -73,7 +78,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : heldByPause + : heldBy ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' @@ -85,6 +90,7 @@ export function projectQueuedMessageCards( text: queuedMessageCardText(message.body), state: message.state, hold, + ...(hold === 'queue-paused' && heldBy ? { queuePause: { reason: heldBy.reason } } : {}), ...(message.pausedReason !== undefined ? { pausedReason: message.pausedReason } : {}), ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), ...(message.returnedRejection !== undefined @@ -94,20 +100,27 @@ export function projectQueuedMessageCards( }) } -/** Whether Resume would release anything: nothing runs, and a queue-level pause holds a card. One - * held on its own, returned or behind a returned card would not send. `isWorking` counts the - * queue's coming send, which the host names. */ +/** The pause the header row names, from the oldest card it holds. 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[] +): { reason: string } | null { + return cards.find((card) => card.hold === 'queue-paused')?.queuePause ?? null +} + +/** Whether the composer's Resume would release anything: nothing runs, and a pause holds a card. + * `isWorking` counts the queue's coming send, which the host names. */ export function queuedMessagesResumable( cards: readonly QueuedMessageCard[], isWorking: boolean ): boolean { - return !isWorking && cards.some((card) => card.hold === 'queue-paused') + return !isWorking && queuedMessagesQueuePause(cards) !== null } -/** Steer jumps into a running turn; with none running, or for a card held on its own or - * returned, the action is plainly Send. */ -export function queuedMessageCardSteers(card: QueuedMessageCard, turnRunning: boolean): boolean { - return turnRunning && card.hold !== 'paused' && card.hold !== 'returned' +/** 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. */ 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..f66621c81fda --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts @@ -0,0 +1,89 @@ +// 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 UseNativeChatStructuredComposerSendArgs +} from './use-native-chat-structured-composer-send' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +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 is the person's turn, 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 + count: number + clear: () => Promise +} + +export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructuredComposerSendArgs): { + send: StructuredComposerSend + confirm: NativeChatQueueSendConfirm | null +} { + const sendNow = useNativeChatStructuredComposerSend(args) + const { agent, structuredTransport } = args + const queueHold = structuredTransport?.queueHold + const [pending, setPending] = useState(null) + const [open, setOpen] = useState(false) + // Clear queue sends once its deletes settle, through the send of that render. + const sendNowRef = useRef(sendNow) + useLayoutEffect(() => { + sendNowRef.current = sendNow + }, [sendNow]) + + const send = useCallback( + (text, attachments) => { + if ( + queueHold && + structuredTransport && + !isNativeChatStructuredHostCommand(text, agent, structuredTransport) + ) { + setPending({ text, attachments, count: queueHold.count, clear: queueHold.clear }) + setOpen(true) + return + } + sendNow(text, attachments) + }, + [agent, queueHold, sendNow, structuredTransport] + ) + + const sendMessage = useCallback(() => { + setOpen(false) + if (pending) { + sendNowRef.current(pending.text, pending.attachments) + } + }, [pending]) + const clearQueue = useCallback(() => { + setOpen(false) + if (!pending) { + return + } + void pending.clear().then((cleared) => { + if (cleared) { + sendNowRef.current(pending.text, pending.attachments) + } + }) + }, [pending]) + const dismiss = useCallback(() => setOpen(false), []) + + const confirm = pending ? { open, count: pending.count, clearQueue, sendMessage, dismiss } : null + return { send, 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 f208e6cb4a93..f01023fb4958 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 @@ -23,6 +23,18 @@ 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)) + ) +} + /** 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({ @@ -48,9 +60,7 @@ export function useNativeChatStructuredComposerSend({ if (!structuredTransport) { return } - const hostCommand = - isStructuredAgentSessionComposerCommand(text, agent) || - (structuredTransport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text)) + const hostCommand = isNativeChatStructuredHostCommand(text, agent, structuredTransport) if (attachments.length > 0 && hostCommand) { structuredTransport.onError('Remove attachments before using a chat-session command.') return 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..eff432dd7a12 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx @@ -0,0 +1,134 @@ +// @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 + 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: false, + 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, { heldBy: STOPPED }), + card('typed-after', 2, { heldBy: null }), + card('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: null }) + ]) + 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('typed-after', 1, { heldBy: null })] + expect(renderController(unheld).result.current.queueHold).toBeUndefined() + const ownHeld = [card('failed', 1, { paused: true, heldBy: null })] + expect(renderController(ownHeld).result.current.queueHold).toBeUndefined() + expect(renderController([]).result.current.queueHold).toBeUndefined() + const held = [card('held', 1, { heldBy: STOPPED })] + expect(renderController(held, { enabled: false }).result.current.queueHold).toBeUndefined() + }) + + it('Clear queue deletes every card shown, and answers true once all are gone', async () => { + const { result, mutate } = renderController([ + card('held', 1, { heldBy: STOPPED }), + card('typed-after', 2, { heldBy: null }) + ]) + 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, { heldBy: STOPPED }), card('typed-after', 2, { heldBy: null })], + { 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 6c81e40dfa06..068e0eb8ef69 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,8 +1,9 @@ // @vitest-environment happy-dom // Resume releases a held queue through its own RPC, over the same fenced write every card action -// uses: offered only while no turn runs and the host holds a card it would send; a refusal or a -// failure is one toast, and Resume stays the way to try again. +// 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 { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -118,15 +119,56 @@ describe('whether Resume is offered', () => { }) }) -describe('while the queue is about to send its next card', () => { - it('the cards steer and Resume is not offered: the chat reads as working', () => { +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.turnRunning).toBe(true) + expect(result.current.pause).toEqual({ reason: 'stopped' }) expect(result.current.queueResume).toBeUndefined() }) }) +describe("the header row's pause", () => { + it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( + "names the reason the host holds a card 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 heldBy = { reason } as AgentSessionQueuePause + const { result } = renderController({ queuedMessages: [card('held', { heldBy })] }) + expect(result.current.pause).toEqual({ reason }) + } + ) + + it('is absent over cards held only on their own or returned, and over cards no pause holds', () => { + const queuedMessages = [ + card('failed', { paused: true, pausedReason: 'send_failed', heldBy: null }), + card('returned', { position: 2, state: 'returned', heldBy: null }) + ] + expect(renderController({ queuedMessages }).result.current.pause).toBeNull() + const typedAfter = [card('typed-after', { heldBy: null })] + expect(renderController({ queuedMessages: typedAfter }).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() + 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('Resume on a held queue', () => { it('calls queuedMessagesResume with only an envelope, and says nothing when it lands', async () => { mocks.call.mockResolvedValue(RESUMED) 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 9db0e95ded3a..16b1bfd7a8b3 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,5 +1,6 @@ // Host-held drafts as this pane acts on them: the card list, Send-now (Steer), -// Delete, Edit, the Cmd/Ctrl+Enter steer chord, and Resume of a held queue. +// 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. @@ -19,16 +20,23 @@ import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + queuedMessagesQueuePause, queuedMessagesResumable, 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[] - /** A turn is running, or the queue is about to send its next card, so Send-now steers rather - * than starting a turn. */ - turnRunning: boolean + /** 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 + /** Lift the queue's 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 /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -39,6 +47,8 @@ export type StructuredAgentSessionQueuedMessagesController = { /** Present while no turn runs and the host holds a card Resume would send: the composer offers * Resume. A failure is a toast, and Resume stays the retry. */ queueResume: StructuredAgentSessionQueueResume | undefined + /** Present while the header row shows: a new message first asks whether to clear the cards. */ + queueHold: NativeChatQueueHold | undefined } export type StructuredAgentSessionQueueResume = { @@ -66,12 +76,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - // Held cards carry no caption; Resume, a card's Send or Steer, or a new message releases them. - const queuePaused = args.queuePause !== null + const { queuePause } = args const cards = useMemo( - () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePaused }), - [hasPendingPrompt, queuePaused, queuedMessages, submissions] + () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePause }), + [hasPendingPrompt, queuePause, queuedMessages, submissions] ) const cardsRef = useRef(cards) useEffect(() => { @@ -107,9 +116,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', @@ -118,9 +129,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 => @@ -188,5 +208,26 @@ export function useStructuredAgentSessionQueuedMessages(args: { [resumable, resume, resuming] ) - return { cards, turnRunning: args.isWorking, steer, remove, edit, steerNewest, queueResume } + const pause = useMemo(() => queuedMessagesQueuePause(cards), [cards]) + // Every card shown, held or not: Clear queue empties the list the person sees. + const clear = useCallback(async (): Promise => { + const removed = await Promise.all(cardsRef.current.map((card) => removeCard(card.messageId))) + return removed.every(Boolean) + }, [removeCard]) + const count = cards.length + const held = enabled && pause !== null + const queueHold = useMemo(() => (held ? { count, clear } : undefined), [held, count, clear]) + + return { + cards, + pause, + resume, + resuming, + steer, + remove, + edit, + steerNewest, + queueResume, + queueHold + } } 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 a4308b5f11d0..c373afe6f31e 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 @@ -281,7 +281,7 @@ describe('against a capable host', () => { nextQueuedMessageId = 'draft-1' const { result } = render() expect(result.current).toMatchObject({ isWorking: true, queueSendsNext: true, canStop: false }) - expect(result.current.queuedMessages.turnRunning).toBe(true) + 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() diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b20c7037d554..a09cce7ec8e4 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2808,6 +2808,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 624bcd12df52..bd4de95851f3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18072,7 +18072,17 @@ "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", "paused": "Paused", - "resume": "Resume" + "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" }, "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1891e56aa128..e90eca47f837 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15220,7 +15220,17 @@ "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", "paused": "En pausa", - "resume": "Reanudar" + "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" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ebf3b163b2aa..78d1e28eb95b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17905,7 +17905,17 @@ "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", "paused": "En pause", - "resume": "Reprendre" + "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" }, "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b6891dc4d608..1434688c8974 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17842,7 +17842,17 @@ "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", "paused": "一時停止中", - "resume": "再開" + "queuePausedStopped": "中断したため、キューを一時停止しました", + "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", + "queuePausedCleared": "会話を消去したため、キューを一時停止しました", + "queuePaused": "キューは一時停止中です", + "resume": "再開", + "sendConfirmTitle": "メッセージを送信しますか?", + "sendConfirmBody": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmBody_one": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmBody_other": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmClear": "キューを消去", + "sendConfirmSend": "メッセージを送信" }, "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3c3960fe8023..91b73ef25d48 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17842,7 +17842,17 @@ "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", "paused": "일시 중지됨", - "resume": "재개" + "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", + "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", + "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", + "queuePaused": "대기열이 일시 중지되었습니다", + "resume": "재개", + "sendConfirmTitle": "메시지를 보낼까요?", + "sendConfirmBody": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmBody_one": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmBody_other": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmClear": "대기열 지우기", + "sendConfirmSend": "메시지 보내기" }, "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 16bad88eba84..83cdad1e4dcd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17859,7 +17859,17 @@ "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", "paused": "已暂停", - "resume": "继续" + "queuePausedStopped": "因你中断了操作,队列已暂停", + "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", + "queuePausedCleared": "清除对话后,队列已暂停", + "queuePaused": "队列已暂停", + "resume": "继续", + "sendConfirmTitle": "发送消息?", + "sendConfirmBody": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmBody_one": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmBody_other": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmClear": "清空队列", + "sendConfirmSend": "发送消息" }, "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", From 8ef6ea4f89eb946a8529d4210cbd2289a1f65ca6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:46:27 -0700 Subject: [PATCH 17/31] fix(native-chat): the paused row goes while your own message is on its way to lift it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After "Send message" over a held queue, the row kept saying "Queue paused…" until the agent accepted the new turn. The chat now reads that gap from the outbox: while this composer's direct send is recorded by the host and not yet accepted, the controller shows no paused row (and so no Resume or confirmation). A refusal settles the entry and the row comes back, since the hold did not lift. Orca's own sends never enter this outbox, and the queue's send of a card goes under a fresh id, so neither hides it. Nothing is stored. --- ...ed-agent-session-queued-card-holds.test.ts | 77 ++++++++++++++++--- ...uctured-agent-session-queued-cards.test.ts | 27 +++++++ .../structured-agent-session-queued-cards.ts | 16 ++++ ...ent-session-queued-messages.clear.test.tsx | 14 +++- ...tructured-agent-session-queued-messages.ts | 8 +- ...tured-agent-session.queued-gating.test.tsx | 49 +++++++++++- .../use-structured-agent-session.ts | 6 +- 7 files changed, 180 insertions(+), 17 deletions(-) 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 index 65c1674c2889..3aae992262bd 100644 --- 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 @@ -12,6 +12,11 @@ import type { } from '../../../shared/agent-session-wire' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from '../../../shared/structured-agent-session-outbox' +import { + ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause, @@ -81,8 +86,31 @@ type ClientView = { nextQueuedMessageId: string | null } +/** This client's composer sends, settled against the journal as the outbox settles them. */ +type ComposerOutbox = { entries: StructuredAgentSessionOutboxEntry[] } + +/** A composer message on its way, as the outbox holds it once the request went out. */ +function composerSend( + outbox: ComposerOutbox, + text: string +): ReturnType { + const sent = rig.send(text, 'queue-if-active') + outbox.entries.push({ + clientMessageId: sent.id, + sessionId: HOST_TEST_SESSION, + body: hostTestMessage(text), + previewUris: [], + state: 'dispatching', + queuedAt: 1, + lastAttemptAt: 1, + retryAfterUnknownSubmittedAt: null, + sentDelivery: 'queue-if-active' + }) + return sent +} + /** Folds every published update as the chat does, one at a time, into what it shows. */ -async function watchClient(): Promise { +async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise { const views: ClientView[] = [] const submissions = new Map() const turns = new Map() @@ -117,6 +145,7 @@ async function watchClient(): Promise { const working = hostWorking || (nextQueuedMessageId !== null && !hostWorking) const cards = projectQueuedMessageCards(queued, all, { hasPendingPrompt: false, queuePause }) const queueHeld = queuedMessagesResumable(cards, working) + outbox.entries = reconcileStructuredAgentSessionOutbox(outbox.entries, all) views.push({ working, stopLive: hostWorking, @@ -125,7 +154,8 @@ async function watchClient(): Promise { composerEmpty: true, queueHeld }), - header: queuedMessagesQueuePause(cards) !== null, + header: + !ownDirectSendOnItsWay(outbox.entries, all) && queuedMessagesQueuePause(cards) !== null, steers: cards.map((card) => queuedMessageCardSteers(card)), cards: cards.length, nextQueuedMessageId @@ -193,36 +223,63 @@ describe('which pause holds each card', () => { }) describe("a message sent over a held queue (the confirmation's Send message)", () => { - it("goes out at once as the person's turn; the held cards stay, the header goes once that turn starts, and they follow in order", async () => { + /** Two cards a Stop holds, the stopped turn over: the paused row and Resume show. */ + async function heldIdleQueue(outbox: ComposerOutbox) { 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() + const views = await watchClient(outbox) expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', cards: 2 }) + return { first, second, views } + } + + it("goes out at once as the person's turn: the paused row goes as it is sent and never comes back, and the held cards follow it in order", async () => { + const outbox: ComposerOutbox = { entries: [] } + const { first, second, views } = await heldIdleQueue(outbox) // Sent with the composer's queue delivery: no card ahead may send, so nothing queues it. - const message = rig.send('a new instruction', 'queue-if-active') + const message = composerSend(outbox, '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. expect(await heldBy()).toEqual({ [first]: { reason: 'stopped' }, [second]: { reason: 'stopped' } }) + 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()) - // The header goes while both cards still wait: the message's turn lifted the hold. - const lifted = views.findIndex((view) => !view.header) - expect(views[lifted]?.cards).toBe(2) - // Once lifted it stays lifted, and no card ever reads Send. - expect(views.slice(lifted).filter((view) => view.header)).toEqual([]) + // 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('a send the agent refuses lifts nothing: the paused row comes back over the held cards', async () => { + const outbox: ComposerOutbox = { entries: [] } + const { first, views } = await heldIdleQueue(outbox) + const message = composerSend(outbox, '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", () => { 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 9c60e50837ad..36562dbd0f98 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 @@ -9,6 +9,7 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { newestSteerableQueuedMessageCard, outboxOutsideQueuedCards, + ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause, @@ -227,6 +228,32 @@ describe('queued message cards', () => { ).toBeNull() }) + it("only the person's own direct send, recorded and not yet accepted, is on its way to lift a pause", () => { + const entry = ( + clientMessageId: string, + state: StructuredAgentSessionOutboxEntry['state'] = 'dispatching' + ): StructuredAgentSessionOutboxEntry => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state, + queuedAt: 1, + lastAttemptAt: 1, + retryAfterUnknownSubmittedAt: null + }) + expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine')])).toBe(true) + // Not yet recorded: it may still become a card. + expect(ownDirectSendOnItsWay([entry('mine')], [])).toBe(false) + // Orca's own send never enters this outbox. + expect(ownDirectSendOnItsWay([], [submission('orca')])).toBe(false) + // The queue's send of a card goes under a fresh id, even for a card this composer queued. + expect(ownDirectSendOnItsWay([entry('card')], [handOff('card')])).toBe(false) + expect(ownDirectSendOnItsWay([entry('mine', 'rejected')], [submission('mine')])).toBe(false) + expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'rejected')])).toBe(false) + expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'unknown')])).toBe(false) + }) + it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index ff08241d3602..1307286fb7b2 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 @@ -108,6 +108,22 @@ export function queuedMessagesQueuePause( return cards.find((card) => card.hold === 'queue-paused')?.queuePause ?? null } +/** The person's own message from this composer has reached the host as a direct send and waits + * for the agent to accept it. Its turn will lift the queue's pause, so the header row does not + * say paused meanwhile; a refusal settles the entry and the row comes back. An Orca send never + * enters this outbox, and the queue's send of a card goes under a fresh id. */ +export function ownDirectSendOnItsWay( + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): boolean { + const pending = new Set( + submissions + .filter((submission) => submission.dispatchState === 'pending') + .map((submission) => submission.clientMessageId) + ) + return outbox.some((entry) => entry.state !== 'rejected' && pending.has(entry.clientMessageId)) +} + /** Whether the composer's Resume would release anything: nothing runs, and a pause holds a card. * `isWorking` counts the queue's coming send, which the host names. */ export function queuedMessagesResumable( 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 index eff432dd7a12..8549d1b7862a 100644 --- 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 @@ -32,6 +32,7 @@ function renderController( options: { queuePause?: AgentSessionQueuePause | null enabled?: boolean + ownSendOnItsWay?: boolean answer?: (messageId: string) => DeleteAnswer } = {} ) { @@ -47,7 +48,8 @@ function renderController( queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, submissions: [], hasPendingPrompt: false, - isWorking: false, + isWorking: options.ownSendOnItsWay === true, + ownSendOnItsWay: options.ownSendOnItsWay === 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 @@ -82,6 +84,16 @@ describe('the held queue a new message asks about', () => { expect(renderController(held, { enabled: false }).result.current.queueHold).toBeUndefined() }) + it("goes, with the paused row, while the person's own message is on its way to lift the pause", () => { + const held = [card('held', 1, { heldBy: STOPPED })] + const { result } = renderController(held, { ownSendOnItsWay: true }) + expect(result.current.pause).toBeNull() + expect(result.current.queueHold).toBeUndefined() + expect(result.current.queueResume).toBeUndefined() + // The held card is unchanged: only the row says nothing while the turn is on its way. + expect(result.current.cards.map((entry) => entry.hold)).toEqual(['queue-paused']) + }) + it('Clear queue deletes every card shown, and answers true once all are gone', async () => { const { result, mutate } = renderController([ card('held', 1, { heldBy: STOPPED }), 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 16b1bfd7a8b3..a64baf63d96a 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 @@ -72,6 +72,8 @@ export function useStructuredAgentSessionQueuedMessages(args: { hasPendingPrompt: boolean /** A turn is running, whoever started it, or the queue is about to send its next card. */ isWorking: boolean + /** The person's own message is on its way to start the turn that lifts the pause. */ + ownSendOnItsWay?: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { @@ -208,7 +210,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { [resumable, resume, resuming] ) - const pause = useMemo(() => queuedMessagesQueuePause(cards), [cards]) + const ownSendOnItsWay = args.ownSendOnItsWay === true + const pause = useMemo( + () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards)), + [cards, ownSendOnItsWay] + ) // Every card shown, held or not: Clear queue empties the list the person sees. const clear = useCallback(async (): Promise => { const removed = await Promise.all(cardsRef.current.map((card) => removeCard(card.messageId))) 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 c373afe6f31e..09d3b1e2d915 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 @@ -9,8 +9,14 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -24,6 +30,8 @@ const mocks = vi.hoisted(() => ({ let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined let nextQueuedMessageId: string | null = null +let queuePause: AgentSessionQueuePause | null = null +let submissions: AgentJournalSubmission[] = [] let outboxEntries: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -36,11 +44,11 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions: [], + submissions, status: 'ready', error: null, hasOlder: false, - ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId } : {}) + ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId, queuePause } : {}) }, loadingOlder: false, loadOlder: vi.fn() @@ -129,6 +137,8 @@ beforeEach(() => { items = [RUNNING_TURN] queuedMessages = undefined nextQueuedMessageId = null + queuePause = null + submissions = [] outboxEntries = [] localStorage.clear() clearNativeChatDraftCacheForTests() @@ -288,6 +298,37 @@ describe('against a capable host', () => { expect(refused.result.current).toMatchObject({ isWorking: false, queueSendsNext: false }) }) + it("hides the paused row while the person's own message is on its way, and shows it again once that send is refused", () => { + items = [] + queuePause = { reason: 'stopped' } + queuedMessages = [{ ...draft('held'), heldBy: queuePause }] + const held = render() + expect(held.result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) + const sent = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'mine', + sessionId: 'session-1', + text: 'new', + attachments: [], + queuedAt: 1 + }) + const recorded: AgentJournalSubmission = { + clientMessageId: 'mine', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + outboxEntries = [{ ...sent, state: 'dispatching', lastAttemptAt: 1 }] + submissions = [recorded] + expect(render().result.current.queuedMessages.pause).toBeNull() + outboxEntries = [{ ...sent, state: 'rejected', lastAttemptAt: 1 }] + submissions = [{ ...recorded, dispatchState: 'rejected', resolvedAt: 2 }] + expect(render().result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) + }) + 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 3ebf0494809c..8a95f270986a 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 @@ -35,7 +35,10 @@ import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-sess import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage' import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' -import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' +import { + outboxOutsideQueuedCards, + ownDirectSendOnItsWay +} from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' @@ -176,6 +179,7 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, isWorking, + ownSendOnItsWay: ownDirectSendOnItsWay(outbox, transportState.submissions), composerScopeKey, mutate }) From dfe173825bec15c1c1e6bc264b44a3b02d969665 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:58:27 -0700 Subject: [PATCH 18/31] fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast The closing dialog stays mounted and clickable through its exit animation, and a double-click or a held Enter lands twice before any re-render, so Send message (or Clear queue) could send the captured message twice. The pending send now lives in a ref that the first choice takes; a second one finds nothing. Clear queue deletes one card at a time and stops at the first failure, so a failed press shows one toast instead of one per card. The dialog keeps its compact width at desktop sizes and the primitive's narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact confirmations). --- .../NativeChatQueueSendConfirmDialog.tsx | 2 +- .../native-chat-queue-send-confirm.test.tsx | 66 +++++++++++++++++++ ...se-native-chat-held-queue-composer-send.ts | 35 ++++++---- ...nt-session-queued-messages.resume.test.tsx | 15 +++++ ...tructured-agent-session-queued-messages.ts | 15 +++-- 5 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx index 11ad56ff6508..b2cdc032f785 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx @@ -32,7 +32,7 @@ export function NativeChatQueueSendConfirmDialog({ }} > { event.preventDefault() 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 index 1262cb878a39..e3c99ff5ce5a 100644 --- 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 @@ -230,6 +230,72 @@ describe('sending while the queue is held', () => { 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() + } + }) + }) + 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) 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 index f66621c81fda..615ff96b300c 100644 --- 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 @@ -43,6 +43,15 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured 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) + const take = useCallback((): PendingSend | null => { + const taken = untakenRef.current + untakenRef.current = null + setOpen(false) + return taken + }, []) // Clear queue sends once its deletes settle, through the send of that render. const sendNowRef = useRef(sendNow) useLayoutEffect(() => { @@ -56,7 +65,9 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured structuredTransport && !isNativeChatStructuredHostCommand(text, agent, structuredTransport) ) { - setPending({ text, attachments, count: queueHold.count, clear: queueHold.clear }) + const asked = { text, attachments, count: queueHold.count, clear: queueHold.clear } + untakenRef.current = asked + setPending(asked) setOpen(true) return } @@ -66,23 +77,25 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured ) const sendMessage = useCallback(() => { - setOpen(false) - if (pending) { - sendNowRef.current(pending.text, pending.attachments) + const taken = take() + if (taken) { + sendNowRef.current(taken.text, taken.attachments) } - }, [pending]) + }, [take]) const clearQueue = useCallback(() => { - setOpen(false) - if (!pending) { + const taken = take() + if (!taken) { return } - void pending.clear().then((cleared) => { + void taken.clear().then((cleared) => { if (cleared) { - sendNowRef.current(pending.text, pending.attachments) + sendNowRef.current(taken.text, taken.attachments) } }) - }, [pending]) - const dismiss = useCallback(() => setOpen(false), []) + }, [take]) + const dismiss = useCallback(() => { + take() + }, [take]) const confirm = pending ? { open, count: pending.count, clearQueue, sendMessage, dismiss } : null return { send, confirm } 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 068e0eb8ef69..87aa65a665e4 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 @@ -169,6 +169,21 @@ describe("the header row's pause", () => { }) }) +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) 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 a64baf63d96a..c72e9b6d2690 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 @@ -215,11 +215,16 @@ export function useStructuredAgentSessionQueuedMessages(args: { () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards)), [cards, ownSendOnItsWay] ) - // Every card shown, held or not: Clear queue empties the list the person sees. - const clear = useCallback(async (): Promise => { - const removed = await Promise.all(cardsRef.current.map((card) => removeCard(card.messageId))) - return removed.every(Boolean) - }, [removeCard]) + // 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 held = enabled && pause !== null const queueHold = useMemo(() => (held ? { count, clear } : undefined), [held, count, clear]) From 818ec9acdc556c54d19fd0602b37323ecc87bd2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:24:34 -0700 Subject: [PATCH 19/31] fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits After Clear queue, the message waited in the composer while the cards were deleted one by one. A second Enter in that window sent it again, and text typed meanwhile was wiped when the chained send was accepted. From the Clear queue choice until its message has gone out, the composer's structured send does nothing. The chained send (and Send message's) now carries the composition it was taken from, and the composer is cleared on acceptance only if it still holds exactly that, as host commands already do. Also: the v1 contract comment names `nextQueuedMessageId` and its absent- means-null fallback, and the own-send check returns at once on an empty outbox. --- .../native-chat-queue-send-confirm.test.tsx | 47 +++++++++++++++++++ .../structured-agent-session-queued-cards.ts | 3 ++ ...se-native-chat-held-queue-composer-send.ts | 45 ++++++++++++++---- ...se-native-chat-structured-composer-send.ts | 31 ++++++++---- src/shared/protocol-version.ts | 6 ++- 5 files changed, 111 insertions(+), 21 deletions(-) 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 index e3c99ff5ce5a..6a820c915dd9 100644 --- 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 @@ -296,6 +296,53 @@ describe('sending while the queue is held', () => { }) }) + 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('text typed meanwhile stays in the composer once the message is accepted', 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('start over, 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) 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 1307286fb7b2..18d8aa6ac2ba 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 @@ -116,6 +116,9 @@ export function ownDirectSendOnItsWay( outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[] ): boolean { + if (outbox.length === 0) { + return false + } const pending = new Set( submissions .filter((submission) => submission.dispatchState === 'pending') 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 index 615ff96b300c..b53fec950a70 100644 --- 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 @@ -5,6 +5,8 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { isNativeChatStructuredHostCommand, useNativeChatStructuredComposerSend, + type NativeChatComposerComposition, + type NativeChatStructuredComposerSend, type UseNativeChatStructuredComposerSendArgs } from './use-native-chat-structured-composer-send' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -32,6 +34,8 @@ type PendingSend = { attachments: readonly NativeChatComposerImageAttachment[] | undefined count: number clear: () => Promise + /** The composer as it was when the message was asked for. */ + sentFrom: NativeChatComposerComposition } export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructuredComposerSendArgs): { @@ -53,25 +57,42 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured return taken }, []) // Clear queue sends once its deletes settle, through the send of that render. - const sendNowRef = useRef(sendNow) + const sendNowRef = useRef(sendNow) + const compositionRef = useRef({ + draft: args.draft, + imageAttachments: args.imageAttachments + }) useLayoutEffect(() => { sendNowRef.current = sendNow - }, [sendNow]) + compositionRef.current = { draft: args.draft, imageAttachments: args.imageAttachments } + }, [args.draft, args.imageAttachments, 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, count: queueHold.count, clear: queueHold.clear } + const asked = { + text, + attachments, + count: queueHold.count, + clear: queueHold.clear, + sentFrom: compositionRef.current + } untakenRef.current = asked setPending(asked) setOpen(true) return } - sendNow(text, attachments) + void sendNow(text, attachments) }, [agent, queueHold, sendNow, structuredTransport] ) @@ -79,7 +100,7 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured const sendMessage = useCallback(() => { const taken = take() if (taken) { - sendNowRef.current(taken.text, taken.attachments) + void sendNowRef.current(taken.text, taken.attachments, taken.sentFrom) } }, [take]) const clearQueue = useCallback(() => { @@ -87,11 +108,17 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured if (!taken) { return } - void taken.clear().then((cleared) => { - if (cleared) { - sendNowRef.current(taken.text, taken.attachments) + clearingRef.current = true + void (async () => { + try { + if (await taken.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() 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 f01023fb4958..ca5942fd6153 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 @@ -35,6 +35,20 @@ export function isNativeChatStructuredHostCommand( ) } +/** What the composer held when a send was asked for. */ +export type NativeChatComposerComposition = { + draft: string | undefined + imageAttachments: readonly NativeChatComposerImageAttachment[] +} + +/** A structured send. `sentFrom`: the composition the message was taken from, for a send that + * goes out later than it was asked for; the composer is then cleared only if still unchanged. */ +export type NativeChatStructuredComposerSend = ( + text: string, + attachments?: readonly NativeChatComposerImageAttachment[], + sentFrom?: NativeChatComposerComposition +) => 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({ @@ -47,16 +61,13 @@ export function useNativeChatStructuredComposerSend({ setHistory, setDraft, setCaret -}: UseNativeChatStructuredComposerSendArgs): ( - text: string, - attachments?: readonly NativeChatComposerImageAttachment[] -) => void { - const composition = useRef({ draft, imageAttachments }) +}: UseNativeChatStructuredComposerSendArgs): NativeChatStructuredComposerSend { + const composition = useRef({ draft, imageAttachments }) useLayoutEffect(() => { composition.current = { draft, imageAttachments } }, [draft, imageAttachments]) - return useCallback( - (text: string, attachments = imageAttachments): void => { + return useCallback( + async (text, attachments = imageAttachments, sentFrom): Promise => { if (!structuredTransport) { return } @@ -65,8 +76,8 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.onError('Remove attachments before using a chat-session command.') return } - const submitted = composition.current - void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) + const submitted = sentFrom ?? composition.current + await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error }) => { structuredTransport.onError(error) if (!accepted) { @@ -82,7 +93,7 @@ export function useNativeChatStructuredComposerSend({ ) setHistory((previous) => pushHistory(previous, text)) if ( - hostCommand && + (hostCommand || sentFrom !== undefined) && (composition.current.draft !== submitted.draft || composition.current.imageAttachments !== submitted.imageAttachments) ) { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4305f340d029..7ccde8122752 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -218,8 +218,10 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // 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. Each card may carry `heldBy`, the pause holding it or null; when it is absent (an older -// host) a client falls back to `queuePause` for every card. A card's own hold (`paused`) means only -// that its conversion failed. The host mechanism lands first; the constant gates the rollout. +// host) a client falls back to `queuePause` for every card. `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's own hold (`paused`) means only that its conversion failed. +// The host mechanism lands first; the constant gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific From af021dbea3e04faf049c194dc3e87162e4c65060 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:50:58 -0700 Subject: [PATCH 20/31] fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself - Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person, Orca's own messages, or the queue), and so does Resume. The card and submission author fields that only fed the old person-only rule are gone. - The queue sends strictly in order: a card never overtakes a held one. - After a restart nothing sends by itself and no paused row shows: the chat's next turn (the carry-on, or the person's own message) runs first, then the cards. - Resume and "Send message?" are offered only while nothing runs and no prompt waits. --- .../MobileNativeChatQueuedMessages.test.tsx | 3 +- ...le-structured-queued-message-cards.test.ts | 3 - .../mobile-structured-queued-message-cards.ts | 1 - .../journal-dispatch-reducer.ts | 4 +- .../journal-queued-messages.ts | 13 +- .../agent-session-journal/journal-reducer.ts | 8 +- .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 8 +- .../journal-store-collaborators.ts | 2 +- .../journal-store-contracts.ts | 2 - .../journal-submission-fold.ts | 17 +- .../journal-submission-queued-link.test.ts | 6 +- ...queued-message-bookkeeping-failure.test.ts | 6 +- .../queued-message-delivered-echo.test.ts | 3 +- .../queued-message-pause.test.ts | 91 ++---- .../queued-message-pause.ts | 95 +++---- .../queued-message-schema.ts | 4 +- .../queued-message-store.test.ts | 64 +---- .../queued-message-table.ts | 29 +- ...d-agent-session-claude-queued-stop.test.ts | 11 +- ...t-session-claude-stop-ends-session.test.ts | 3 +- ...-session-claude-unproven-stop-send.test.ts | 3 +- ...tured-agent-session-codex-stop-row.test.ts | 3 +- ...structured-agent-session-host-mutations.ts | 6 - ...structured-agent-session-mutation-plans.ts | 4 - ...d-agent-session-newer-content-host.test.ts | 3 +- ...d-agent-session-queued-card-author.test.ts | 105 ------- ...ed-agent-session-queued-card-holds.test.ts | 266 ++++++++++++++---- ...tured-agent-session-queued-command.test.ts | 3 +- ...ructured-agent-session-queued-gate.test.ts | 12 +- ...session-queued-message-rig.test-fixture.ts | 8 +- ...ured-agent-session-queued-messages.test.ts | 33 +-- ...tructured-agent-session-queued-messages.ts | 12 +- ...ructured-agent-session-queued-mutations.ts | 5 +- ...ed-agent-session-queued-pause-lift.test.ts | 82 +++--- .../structured-agent-session-queued-pause.ts | 3 +- ...ctured-agent-session-queued-publication.ts | 49 +--- .../structured-agent-session-queued-send.ts | 6 +- ...ured-agent-session-queued-stop-row.test.ts | 101 +++---- .../structured-agent-session-queued-stop.ts | 4 +- .../structured-agent-session-queued-wiring.ts | 2 +- ...ssion-restart-interruption-test-harness.ts | 10 +- ...ent-session-restore-without-import.test.ts | 3 +- ...d-agent-session-stop-event-binding.test.ts | 10 +- ...d-agent-session-stop-event-entries.test.ts | 6 +- ...d-agent-session-stop-event-restart.test.ts | 2 +- .../structured-agent-session-turns.ts | 2 - ...d-agent-session-send-compatibility.test.ts | 6 - ...ctured-agent-session-send-compatibility.ts | 3 +- ...-session-codex-turn-end-settlement.test.ts | 10 +- .../NativeChatQueuedMessageList.test.tsx | 21 +- .../NativeChatQueuedMessageList.tsx | 5 - ...uctured-agent-session-queued-cards.test.ts | 88 ++---- .../structured-agent-session-queued-cards.ts | 38 +-- ...se-native-chat-held-queue-composer-send.ts | 2 +- ...ent-session-queued-messages.clear.test.tsx | 40 +-- ...nt-session-queued-messages.resume.test.tsx | 34 ++- ...tructured-agent-session-queued-messages.ts | 31 +- ...tured-agent-session.queued-gating.test.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - src/shared/agent-session-journal-types.ts | 3 - .../agent-session-queued-message-wire.ts | 13 +- src/shared/protocol-version.ts | 5 +- ...structured-agent-session-coalescer.test.ts | 4 +- 69 files changed, 610 insertions(+), 822 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a57..c44f34f365d6 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -262,7 +262,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) { @@ -298,7 +297,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 44d1e0b9729f..9f512a9b3311 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -105,9 +105,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 f3edb13e53c8..f620f3f2f0ea 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -59,7 +59,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/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index c7c2a6716546..218552fe0210 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 @@ -9,7 +9,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' import { - notePersonTurnAccepted, + noteTurnAccepted, placeHandedOverMessage, placeRejectedMessage } from './journal-submission-fold' @@ -47,7 +47,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 8f9a6b46eacf..f2faa8d758be 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 @@ -12,7 +12,7 @@ import { } from '../../../shared/agent-session-host-authority' import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' -import type { JournalRow, JournalSubmissionOrigin } from './journal-row-schema' +import type { JournalRow } from './journal-row-schema' import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' @@ -110,7 +110,6 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string - origin: JournalSubmissionOrigin }, receipt?: JournalOperationReceipt ): Promise { @@ -158,7 +157,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( @@ -169,16 +168,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) } @@ -258,7 +257,7 @@ export class JournalQueuedMessages { if (input.yieldsToPause) { // Judged again here, by the drain's own rule. Today a Stop cannot land between the drain's // pick and this claim (both run on the session's serialized lane, held across the send), so - // this guards any pause-relevant row written off that lane from sending a card now held. + // this guards any pause-relevant row written off that lane from overtaking a held card. const cards = listQueuedMessages(db, this.deps.sessionId) const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { 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 e2d1b1ef3ca3..f4a76fa886ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -59,9 +59,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 } @@ -82,7 +82,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-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 0865df3a41fa..a994f05c5f97 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -71,7 +71,6 @@ export function journalSubmissionRowBuilder( fence: number handoverRecorded?: true queuedMessageId?: string - origin?: 'client' | 'host' }, /** Present when the append hands off a queued draft: the row names that draft, stamped here * from the consume itself so no hand-off path can leave the link off. */ @@ -307,7 +306,6 @@ export function buildJournalSubmissionRow(input: { ts: number handoverRecorded?: true queuedMessageId?: string - origin?: 'client' | 'host' }): JournalSubmissionRow { return { kind: 'submission', @@ -317,7 +315,6 @@ export function buildJournalSubmissionRow(input: { body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), - ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), - ...(input.origin !== undefined ? { origin: input.origin } : {}) + ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 209d8a8a1335..7b74b025759a 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -139,15 +139,9 @@ export type JournalSubmissionRow = JournalRowBase & { /** The queued draft this submission hands off; absent for a direct send. Older readers keep * the key and ignore it. */ queuedMessageId?: string - /** Who asked for this turn: `client` for a person — their send over the client send RPC, Send - * on a card, or the queue's send of a card they wrote; `host` for Orca's own — orchestration - * mail, a restart continuation, a launch prompt, or the queue's send of a card one of those - * wrote. Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ - origin?: JournalSubmissionOrigin + // Rows from earlier builds may carry `origin` ('client' | 'host'); nothing reads it. } -export type JournalSubmissionOrigin = 'client' | 'host' - export type JournalDispatchRow = JournalRowBase & { kind: 'dispatch' clientMessageId: string 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 dc270bc97a6d..225000bc7ea0 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 @@ -71,7 +71,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-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index cb86a7a8e5da..9d939887ded1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -85,8 +85,6 @@ export type JournalSubmissionInput = { handoverRecorded?: true /** Stamped by `appendSubmission` from its consume; a caller-passed value must match it. */ queuedMessageId?: string - /** Who asked for this turn (`JournalSubmissionRow.origin`). */ - origin?: 'client' | 'host' } /** A submission append that converts a queued draft, in one transaction. */ 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 80e4e2c93e95..f1eaab96b500 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 @@ -29,8 +29,7 @@ export function applyJournalSubmission( // A malformed stored link is dropped, never the row. ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 ? { queuedMessageId: row.queuedMessageId } - : {}), - ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) + : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. @@ -108,7 +107,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 @@ -121,14 +120,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/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index 654e02c5c851..fc9431646158 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -47,8 +47,7 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission messageId: draftId, body: BODY, fingerprint: 'fp', - hostInstance: 'p', - origin: 'client' + hostInstance: 'p' }) await journal.appendSubmission( { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, @@ -97,8 +96,7 @@ describe('the submission names the queued draft it hands off', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp', - hostInstance: 'p', - origin: 'client' + hostInstance: 'p' }) await expect( journal.appendSubmission( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 31e06659667c..15911b5e080c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -64,8 +64,7 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string): messageId, body, fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - origin: 'client' + hostInstance: 'proc-1' }) await journal.appendSubmission( { @@ -172,8 +171,7 @@ describe('draft bookkeeping inside a journal append', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - origin: 'client' + hostInstance: 'proc-1' }) expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) const commit = failNextCommit() diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 02911d773c0d..4ce62c6192b2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -76,8 +76,7 @@ async function handOffAndReject( messageId: 'draft-1', body, fingerprint, - hostInstance: 'p', - origin: 'client' + hostInstance: 'p' }) await journal.appendSubmission( { 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 fb96550c39ea..840398e0a098 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' @@ -62,21 +62,14 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, - origin: 'client', ...(carriedFrom ? { 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, @@ -180,24 +173,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') @@ -220,18 +213,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) @@ -303,7 +296,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, @@ -324,12 +316,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() }) @@ -379,7 +371,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() @@ -393,7 +385,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']) @@ -426,7 +418,6 @@ describe('which cards a pause holds', () => { await journal.appendSubmission( { clientMessageId: 'send-now-1', - origin: 'client', payloadFingerprint: 'fp-steered', body: message('steered'), fence: 0, @@ -448,33 +439,30 @@ describe('which cards a pause holds', () => { ]) }) - it("the queue's own consume takes a newer card past a held one, and refuses the held one", async () => { + it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { const journal = await open() await queueDraft(journal, 'held') await userStop(journal) await queueDraft(journal, 'newer') - const drain = (messageId: string) => + await expect( journal.appendSubmission( { - clientMessageId: `drain-${messageId}`, - origin: 'client', - payloadFingerprint: `fp-${messageId}`, - body: message(messageId), + clientMessageId: 'drain-newer', + payloadFingerprint: 'fp-newer', + body: message('newer'), fence: 0, handoverRecorded: true }, { - messageId, + messageId: 'newer', expect: 'waiting', settledByOp: null, hostInstance: HOST, yieldsToPause: { hostInstance: HOST } } ) - await expect(drain('held')).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - await drain('newer') - expect(journal.queuedMessages.get('newer')?.state).toBe('dispatched') - expect(journal.queuedMessages.get('held')?.state).toBe('waiting') + ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') }) it("'cleared' holds the carried cards, not one typed after them", async () => { @@ -497,8 +485,7 @@ describe("a restart's pause", () => { messageId: 'draft-restart', body: message('written before the restart'), fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0', - origin: 'client' + hostInstance: 'proc-0' }) expect(reason(journal)).toBe('restarted') await queueDraft(journal, 'draft-legacy') @@ -540,7 +527,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', @@ -548,7 +535,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 @@ -585,26 +572,6 @@ describe('which cards the pauses in force hold', () => { expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) }) - it('a restart holds only the cards another process wrote, never one written since', () => { - const cards = [card('before', 1, { hostInstance: DEAD }), card('since', 2)] - expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['restarted']) - expect(holding(cards, 0)).toEqual([ - ['before', 'restarted'], - ['since', null] - ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('since') - }) - - it('the next card skips held ones, but never a returned card', () => { - const held = card('held', 3) - const after = card('after', 5) - expect(nextSendableQueuedCard(pausesOver([held, after]), [held, after])).toBe(after) - const returned = card('returned', 5, { state: 'returned' }) - const behind = card('behind', 6) - const cards = [held, returned, behind] - expect(nextSendableQueuedCard(pausesOver(cards), cards)).toBeNull() - }) - it("a card names the first pause holding it, and the header names the first held card's", () => { const cards = [ card('before', 3, { hostInstance: DEAD }), 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 add2725dc8ff..a4670f6b1493 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,15 +1,14 @@ // 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 was written by another host process, and no person's turn has -// started since this conversation opened. It holds only those cards, never one written since. -// A person's turn is an accepted submission of origin `client`: their send, Send on any card, or -// the queue's send of a card they wrote. Orchestration mail, a restart continuation, a launch -// prompt and the queue's send of a card one of those wrote are `host` and never lift it. +// 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 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. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' @@ -27,12 +26,11 @@ export type JournalQueuePauseMarks = { resumedSequence: number } -export type DerivedQueuePause = - /** `since`: where the Stop was written; a card queued at or after it is newer. */ - | { reason: 'stopped'; since: AgentJournalCursor } - | { reason: 'cleared' } - /** `hostInstance`: this process; a card it wrote came after the restart. */ - | { reason: 'restarted'; hostInstance: string } +export type DerivedQueuePause = { + reason: QueuePauseReason + /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + since: AgentJournalCursor | null +} type QueueCard = { state: string @@ -70,28 +68,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 @@ -100,11 +98,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 } } @@ -113,54 +111,46 @@ 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) { - pauses.push({ reason: 'cleared' }) + if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) { + pauses.push({ reason: 'cleared', since: null }) } if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { - pauses.push({ reason: 'restarted', hostInstance: input.hostInstance }) + // The process that wrote a card is gone: every card waits, whenever it was written. + pauses.push({ reason: 'restarted', since: null }) } return pauses } -/** Queued before the pause began: for /clear, a card it carried; for a restart, a card another - * process wrote. For a Stop, a card queued before its row; one from another epoch (before a - * rewind) or from a build that recorded no position counts as before. A withdrawn steer keeps its - * position, so is held. */ +/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a + * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build + * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { if (pause.reason === 'cleared') { return card.carriedFrom !== null } - if (pause.reason === 'restarted') { - return card.hostInstance !== pause.hostInstance - } const { since } = pause return ( + since === null || card.queuedAt === null || card.queuedAt.epoch !== since.epoch || card.queuedAt.sequence < since.sequence ) } -// Product decision: a card queued AFTER a Stop or a restart is a new instruction and is not held; -// only cards queued before it, and a steer a Stop withdrew, wait. The drain skips held cards, so -// it sends ahead of them; when a person wrote that card its send is their turn, so the held cards -// follow it. 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. */ @@ -171,21 +161,22 @@ 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 that nothing holds, unless a returned - * card comes first. Held cards are skipped, not waited on; a returned card blocks what follows it. - * The drain's pick, its consume, and send admission all read this. */ +/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a + * returned card or a held one comes first. The queue never reorders, so a newer card never + * overtakes a held one. The drain's pick and its consume both read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned') { + if (card.state === 'returned' || queuePauseHolding(pauses, card)) { return null } - if (card.state === 'waiting' && card.holdReason === null && !queuePauseHolding(pauses, card)) { + if (card.state === 'waiting' && card.holdReason === null) { return card } } @@ -193,8 +184,8 @@ export function nextSendableQueuedCard( } /** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned - * card, which blocks everything after it until the user acts. None otherwise, so a client that - * still shows a paused header (mobile, older desktops) never offers a Resume that sends nothing. */ + * card, which blocks everything after it until the user acts. None otherwise, so its header + * never offers a Resume that sends nothing. */ export function resumableQueuePause( pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index d621bbf80e8e..3f9d0122fda2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -12,8 +12,7 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'], - ['origin', 'TEXT'] + ['queued_sequence', 'INTEGER'] ] /** @@ -44,7 +43,6 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, - origin TEXT, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 551e4214cc4c..6c609853d690 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -75,44 +75,15 @@ async function open(): Promise { return journal } -async function queueDraft( - journal: AgentSessionJournal, - messageId: string, - text = 'queued text', - origin: 'client' | 'host' = 'client' -) { +async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { return journal.queuedMessages.insert({ messageId, body: message(text), fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - origin + hostInstance: 'proc-1' }) } -/** A row as a build that knows only the columns it names writes it, the host closed around it. */ -function insertAsOlderBuild(messageId: string, position: number, origin?: string): void { - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - try { - db.prepare( - `INSERT INTO queued_messages (session_id, message_id, position, body_json, fingerprint, - created_at, host_instance, state${origin === undefined ? '' : ', origin'}) - VALUES (?, ?, ?, ?, ?, ?, 'proc-0', 'waiting'${origin === undefined ? '' : ', ?'})` - ).run( - IDENTITY.sessionId, - messageId, - position, - JSON.stringify(message(messageId)), - `fp-${messageId}`, - tick(), - ...(origin === undefined ? [] : [origin]) - ) - } finally { - db.close() - } -} - async function consumeDraft( journal: AgentSessionJournal, messageId: string, @@ -183,37 +154,6 @@ describe('draft rows', () => { expect(row.position).toBe(1) }) - it("keeps who wrote a card; one from before that was recorded reads as a person's", async () => { - const first = await open() - await queueDraft(first, 'draft-host', 'mail', 'host') - await first.close() - // An older build's insert leaves the author empty; a value no build writes is not a person's. - insertAsOlderBuild('draft-older', 2) - insertAsOlderBuild('draft-unknown', 3, 'automation') - const journal = await open() - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ - ['draft-host', 'host'], - ['draft-older', 'client'], - ['draft-unknown', 'host'] - ]) - }) - - it("heals an older build's draft table that has no author column", async () => { - const first = await open() - await queueDraft(first, 'draft-1') - await first.close() - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - db.exec('ALTER TABLE queued_messages DROP COLUMN origin') - db.close() - const journal = await open() - await queueDraft(journal, 'draft-2', 'mail', 'host') - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ - ['draft-1', 'client'], - ['draft-2', 'host'] - ]) - }) - it('assigns monotonic positions and lists in order', async () => { const journal = await open() await queueDraft(journal, 'draft-1') diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 40ff205cad11..a2bd9c3e05ab 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -14,16 +14,14 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -import type { JournalSubmissionOrigin } from './journal-row-schema' import { readStoredRejectionFact } from './journal-dispatch-reducer' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' /** Why ONE waiting draft is held from auto-sending: its conversion failed. * Stored on the row, so it survives handle eviction and restart; a wire marker - * (it publishes as `pausedReason`). A Stop, restart or /clear instead holds the - * cards written before it, derived, and the queue skips held cards. A reader - * treats an unknown stored value as a plain hold. */ + * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue + * instead. A reader treats an unknown stored value as a plain hold. */ export type QueuedMessageHoldReason = 'send_failed' /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and @@ -61,13 +59,10 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null - /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. - * Unrecorded reads as a person's: before it, no production caller queued an Orca send. */ - origin: JournalSubmissionOrigin } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, origin' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' export function insertQueuedMessage( db: Database.Database, @@ -79,7 +74,6 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor - origin: JournalSubmissionOrigin now: number } ): QueuedMessageRow { @@ -90,7 +84,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -101,8 +95,7 @@ export function insertQueuedMessage( input.hostInstance, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence, - input.origin + input.queuedAt.sequence ) return { sessionId: input.sessionId, @@ -120,8 +113,7 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt, - origin: input.origin + queuedAt: input.queuedAt } } @@ -303,7 +295,6 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { carried_from: string | null queued_epoch: string | null queued_sequence: number | null - origin: string | null } let body: AgentJournalMessageItem try { @@ -342,16 +333,10 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null, - origin: storedOrigin(record.origin) + : null } } -/** A row from before the column was a person's send; a value no build writes is not a person's. */ -function storedOrigin(value: string | null): JournalSubmissionOrigin { - return value === null || value === 'client' ? 'client' : 'host' -} - function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null 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 5e4ac75b0662..713e0b9cc570 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 @@ -327,8 +327,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') @@ -345,7 +344,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 } }) @@ -366,14 +365,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 9c920b45eb39..536d865e4804 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 @@ -590,8 +590,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-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 2aac7328966f..65dcfa420738 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -476,8 +476,7 @@ it('queues a follow-up as a draft by default while a message waits, and Steer re const queued = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery, - userSend: true + delivery }) expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) await commitSettled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 165184af7bcd..7f828a484688 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -384,8 +384,7 @@ describe('a Codex Stop whose interrupt failed', () => { }) }, body, - delivery, - userSend: true + delivery }) if (!queued.ok || !('queued' in queued.value)) { throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) 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 6e4b4bc90128..ec56f341e110 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 @@ -54,12 +54,6 @@ export function sendStructuredAgentSessionTurn( body: AgentJournalMessageItem retryUnknown?: true 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; a card it - * queues keeps that origin for the queue's send. Orchestration mail, a restart - * continuation and `agent.launch`'s host-sent prompt never set it. */ - userSend?: true beforeRun?: () => void }, arrival?: Parameters[2] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 6a2a6268ae94..a7223ca8c4ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -76,7 +76,6 @@ export function sendPlan(params: { body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' - userSend?: true beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -97,7 +96,6 @@ export function sendPlan(params: { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() return performSend(ctx, { - origin: params.userSend ? 'client' : 'host', clientMessageId, payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body @@ -161,8 +159,6 @@ export function conversationCommandPlan(params: { run: async (ctx) => { const sent = await performSend(ctx, { clientMessageId, - // Only a client asks through the command RPC: the person's own turn. - origin: 'client', payloadFingerprint: params.envelope.payloadFingerprint, body: structuredAgentSessionCompactBody() }) 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 f3ce31243dad..76cf89fc5a22 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 @@ -141,8 +141,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-author.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts deleted file mode 100644 index 29f3fde2c94d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// A card records who wrote it, and the queue's send of it is that author's turn: a person's card -// ends a Stop's pause, Orca's own does not. /clear carries the author with the card. - -import { afterEach, beforeEach, expect, it } from 'vitest' -import { sendAgentTurn } from '../../runtime/orchestration/send-agent-turn' -import { - HOST_TEST_SESSION as SESSION, - hostTestMessage, - hostTestOperationId -} from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - QUEUED_RIG_CALLER as CALLER, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' - -let rig: QueuedMessageTestRig - -beforeEach(async () => { - rig = await createQueuedMessageTestRig() -}) - -afterEach(() => rig.dispose()) - -async function queuedDraft(text: string, options?: { internal?: true }): Promise { - const queued = await rig.send(text, 'queue-if-active', options).result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error('expected a queued receipt') - } - return queued.value.queued.messageId -} - -/** Each stored card's id and who wrote it. */ -function cardOrigins(sessionId = SESSION): [string, string][] { - const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal - if (!journal) { - throw new Error('no open journal') - } - return journal.queuedMessages.list().map((row) => [row.messageId, row.origin]) -} - -it("a host-authored card queues, and its drained send does not end a Stop's hold; a person's card does", async () => { - const working = await rig.workingSend() - const heldId = await queuedDraft('queued before the stop') - await rig.stop() - // Both queued while the stopped turn winds down, so the Stop holds neither. - const mail = await sendAgentTurn({ - kind: 'structured-session', - host: rig.host, - sessionId: SESSION, - callerKey: 'orchestration-1', - turn: { - body: hostTestMessage('coordinator mail'), - delivery: 'queue', - operationId: hostTestOperationId(), - expectedRuntimeFence: 1 - } - }) - if (mail.kind !== 'queued') { - throw new Error('expected the mail to queue') - } - const typedId = await queuedDraft('typed while stopping') - expect(cardOrigins()).toEqual([ - [heldId, 'client'], - [mail.clientMessageId, 'host'], - [typedId, 'client'] - ]) - await rig.settleAccepted(working, 'a') - // Orca's card goes first, as Orca's turn: the Stop still holds the older card. - await eventually(async () => - expect(await rig.handoff(mail.clientMessageId)).toMatchObject({ origin: 'host' }) - ) - await rig.settleAccepted(await rig.handoffId(mail.clientMessageId), 'b') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - // The person's card is their turn: once it starts, the held card follows. - await eventually(async () => - expect(await rig.handoff(typedId)).toMatchObject({ origin: 'client' }) - ) - expect(await rig.handoff(heldId)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(typedId), 'c') - expect(await rig.queuePause()).toBeNull() - await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) -}) - -it('a card /clear carries keeps who wrote it', async () => { - const working = await rig.workingSend() - const typedId = await queuedDraft('typed text') - const mailId = await queuedDraft('coordinator mail', { internal: true }) - await rig.stop() - await rig.settleAccepted(working, 'a') - const fields = { command: 'clear' as const } - const cleared = await rig.host.conversationCommand(CALLER, { - envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), - ...fields - }) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error('expected a replacement session') - } - expect(cardOrigins(replacementId)).toEqual([ - [typedId, 'client'], - [mailId, 'host'] - ]) -}) 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 index f321484117a7..0e23de428b13 100644 --- 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 @@ -1,8 +1,9 @@ -// The host publishes which pause holds each card and the card its queue 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. Where the host would refuse the send, -// it names no next card, so the chat reads idle. +// 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 { @@ -20,8 +21,7 @@ import { ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, - queuedMessagesQueuePause, - queuedMessagesResumable + queuedMessagesQueuePause } from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' import { nativeChatComposerPrimaryAction, @@ -32,7 +32,11 @@ import { readQueuePublication, structuredQueueSendGate } from './structured-agent-session-queued-publication' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + structuredAgentSessionHostInstance, + structuredQueuePauses +} from './structured-agent-session-queued-pause' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { HOST_TEST_SESSION, hostTestMessage, @@ -47,6 +51,8 @@ import { let rig: QueuedMessageTestRig +const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } + beforeEach(async () => { rig = await createQueuedMessageTestRig({ restartable: true }) }) @@ -61,15 +67,36 @@ async function queuedDraft(text: string): Promise { return queued.value.queued.messageId } -/** Each waiting card's published hold, by id. */ -async function heldBy(): Promise> { +/** 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 Object.fromEntries( - (page.page.queuedMessages ?? []).map((card) => [card.messageId, card.heldBy]) - ) + 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 = { @@ -81,6 +108,8 @@ type ClientView = { 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[] cards: number @@ -146,11 +175,18 @@ async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise queuedMessageCardSteers(card)), cards: cards.length, nextQueuedMessageId @@ -178,37 +214,27 @@ function expectOneWorkingRun(views: readonly ClientView[]): void { expect(views.some((view) => !view.stopLive && view.nextQueuedMessageId !== null)).toBe(true) } -describe('which pause holds each card', () => { - it("a Stop's: the card queued before it, not the one typed while it lands; Resume and Send never show as the queue sends them", async () => { +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') - expect(await heldBy()).toEqual({ [held]: { reason: 'stopped' }, [typed]: null }) 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(async () => expect(await rig.handoff(typed)).toBeDefined()) - await rig.settleAccepted(await rig.handoffId(typed), 'typed') + 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()) - expectOneWorkingRun(views.slice()) - }) - - it("a restart's: the card from before it, not one typed during Orca's own turn since; the same holds as that turn ends", async () => { - const working = await rig.workingSend() - const before = await queuedDraft('written before the restart') - await rig.restartHostProcess() - await rig.settleAccepted(working, 'a') - const continuation = rig.send('continue where you left off', undefined, { internal: true }) - await continuation.result - await eventually(async () => - expect((await rig.submission(continuation.id))?.handedOverAt).toBeDefined() - ) - const typed = await queuedDraft('typed during the continuation') - expect(await heldBy()).toEqual({ [before]: { reason: 'restarted' }, [typed]: null }) - const views = await watchClient() - await rig.settleAccepted(continuation.id, 'continuation') + 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()) + expectOneWorkingRun(views.slice(resumedAt)) }) it('after Resume over held cards the chat goes from idle with Resume straight to working with Stop', async () => { @@ -227,6 +253,154 @@ describe('which pause holds each card', () => { }) }) +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 + }) + 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 outbox: ComposerOutbox = { entries: [] } + const views = await watchClient(outbox) + // Sent with the composer's queue delivery: nothing ahead may send, so it is not queued. + const message = composerSend(outbox, '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('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(outbox: ComposerOutbox) { @@ -236,11 +410,11 @@ describe("a message sent over a held queue (the confirmation's Send message)", ( await rig.stop() await rig.settleAccepted(working, 'stopped') const views = await watchClient(outbox) - expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', cards: 2 }) + expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', dialog: true, cards: 2 }) return { first, second, views } } - it("goes out at once as the person's turn: the paused row goes as it is sent and never comes back, and the held cards follow it in order", async () => { + 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 outbox: ComposerOutbox = { entries: [] } const { first, second, views } = await heldIdleQueue(outbox) // Sent with the composer's queue delivery: no card ahead may send, so nothing queues it. @@ -251,10 +425,7 @@ describe("a message sent over a held queue (the confirmation's Send message)", ( value: { submission: expect.anything() } }) // The cards stay held until the agent accepts the message's turn. - expect(await heldBy()).toEqual({ - [first]: { reason: 'stopped' }, - [second]: { reason: 'stopped' } - }) + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) await eventually(async () => expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() ) @@ -331,8 +502,7 @@ describe('where the host would refuse the send', () => { messageId: card, body: hostTestMessage('left on the source'), fingerprint: 'fp-left-behind', - hostInstance: structuredAgentSessionHostInstance(), - origin: 'client' + hostInstance: structuredAgentSessionHostInstance() }) const record = rig.store.getRecord(HOST_TEST_SESSION) if (!record) { 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 c5a34298a83c..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 @@ -128,8 +128,7 @@ describe('replay preference', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -167,8 +166,7 @@ describe('replay of a deleted card', () => { 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') @@ -211,8 +209,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') @@ -244,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-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f94d091a5f97..cfb4c0321b00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -138,17 +138,15 @@ export async function createQueuedMessageTestRig( } } - /** A client's send, as the `agentSession.send` RPC hands it to the host; - * `internal` is a host-side sender (orchestration mail, a restart continuation). */ - function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) { + /** A send as the host takes it: a client's over the `agentSession.send` RPC, or Orca's own. */ + function send(text: string, delivery?: 'queue-if-active') { const body = hostTestMessage(text) const clientOperationId = hostTestOperationId() const fields = { body, ...(delivery ? { delivery } : {}) } const result = host.send(QUEUED_RIG_CALLER, { envelope: envelope(fields, 'agentSession.send', clientOperationId), body, - ...(delivery ? { delivery } : {}), - ...(options?.internal ? {} : { userSend: true as const }) + ...(delivery ? { delivery } : {}) }) return { id: clientOperationId, result } } 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 3011fa194d58..c5d5b3c77696 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 @@ -79,8 +79,7 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -110,8 +109,7 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active', - userSend: true + delivery: 'queue-if-active' }) expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) expect(await drafts()).toHaveLength(0) @@ -264,7 +262,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 +271,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 +284,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 +295,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()) }) @@ -459,7 +457,7 @@ describe('Stop and Delete', () => { 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 +467,14 @@ 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)) - expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(draftId)).toBeUndefined() + 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 fe6a72bc951b..5689b9144e0e 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 @@ -60,8 +60,9 @@ export function pendingPromptExists(journal: Pick ): QueuedMessageRow | null { @@ -211,7 +212,6 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -252,8 +252,7 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance(), - origin: params.userSend ? 'client' : 'host' + hostInstance: structuredAgentSessionHostInstance() }, ctx.operationReceipt ) @@ -362,9 +361,6 @@ export class StructuredAgentSessionQueuedMessageDrain { await journal.appendSubmission( { clientMessageId: submissionId, - // Whoever wrote the card asked for this turn: a person's ends a Stop's pause, so the cards - // it held follow; Orca's own does not. - origin: next.origin, payloadFingerprint: next.fingerprint, body: next.body, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index d3c06d9609c4..b9bde73d94d9 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 @@ -117,8 +117,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId, - origin: row.origin + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { @@ -222,8 +221,6 @@ 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. - origin: 'client', payloadFingerprint: row.fingerprint, body: row.body, fence: ctx.fence, 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 ef6c6ee95328..154470f2a5af 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 cards queued before it, derived from the journal: it lasts until a turn a -// person asked for (a send over the client RPC, or a card of theirs, sent now or by the queue) -// 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)) { @@ -99,24 +108,27 @@ describe("a Stop's queue pause", () => { rig.crashRestartHostProcess() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) 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') await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it('a draft typed while the stopped turn winds down sends once it ends; the older one follows once that turn starts', async () => { + it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { const working = await rig.workingSend() const olderId = await queuedDraft('paused by the stop') await rig.stop() const typedId = await queuedDraft('typed while stopping') await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(typedId)).toBeDefined()) - await expectPaused(olderId) - await rig.settleAccepted(await rig.handoffId(typedId), 'typed') - await eventually(async () => expect(await rig.handoff(olderId)).toBeDefined()) + await expectPaused(olderId, typedId) + expect(await rig.drafts()).toEqual([ + { messageId: olderId, state: 'waiting' }, + { messageId: typedId, state: 'waiting' } + ]) }) it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { @@ -127,13 +139,13 @@ 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) 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()) }) @@ -166,8 +178,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, @@ -175,15 +186,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) { @@ -194,14 +209,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()) }) @@ -226,7 +238,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() @@ -234,8 +246,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 () => { @@ -342,13 +354,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') @@ -430,12 +443,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 also lifts the restart's pause", async () => { + const draftId = await stoppedDraft() + rig.crashRestartHostProcess() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + 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..6d29bcf74a1b 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,8 +30,7 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ } /** - * Every journal publish: a restart's rows are adopted into this instance once a person's turn - * started. The derivation already reads them as lifted; the write keeps that answer when the + * 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( 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 f4aff39a14c8..18bfa4f91ea5 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 @@ -9,11 +9,7 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { - queuePauseHolding, - resumableQueuePause, - type DerivedQueuePause -} from '../agent-session-journal/queued-message-pause' +import { 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' @@ -42,11 +38,8 @@ export function structuredQueueSendGate( } /** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion); `heldBy` names the queue-level pause holding the card, if any. */ -function computePublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { + * conversion); 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()) { if (row.state !== 'waiting' && row.state !== 'returned') { @@ -63,7 +56,6 @@ function computePublishedQueuedMessages( ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } : {}), - heldBy: heldBy(pauses, row), ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), ...(row.state === 'returned' && row.returnedRejection ? { returnedRejection: row.returnedRejection } @@ -73,33 +65,21 @@ function computePublishedQueuedMessages( return published } -function heldBy( - pauses: readonly DerivedQueuePause[], - row: Parameters[1] -): AgentSessionQueuePause | null { - const holding = queuePauseHolding(pauses, row) - return holding ? { reason: holding.reason } : null -} - type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } /** Reference-stable per journal handle: an unchanged list is never - * re-serialized onto token-stream frames, and any draft-table write or change - * of the pauses in force changes the reference by construction. */ + * re-serialized onto token-stream frames, and any draft-table write changes + * the reference by construction. */ const listMemos = new WeakMap() const publications = new WeakMap() -function readPublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { - // The pauses also turn on journal rows (a Stop, a person's turn), not only on the drafts. - const key = `${journal.queuedMessages.revision()}:${JSON.stringify(pauses)}` +function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const key = String(journal.queuedMessages.revision()) const memo = listMemos.get(journal) if (memo && memo.key === key) { return memo.list } - const list = computePublishedQueuedMessages(journal, pauses) + const list = computePublishedQueuedMessages(journal) // Belt for the identity dedup: equal recomputed content keeps the previous reference. const serialized = JSON.stringify(list) if (memo && memo.serialized === serialized) { @@ -123,13 +103,12 @@ export function readQueuePublication( journal: AgentSessionJournal, gate: QueueSendGate ): QueuePublication { - // Read per emit: the pauses also turn on submissions (a person's turn starting). - const pauses = structuredQueuePauses(journal) - const queuedMessages = readPublishedQueuedMessages(journal, pauses) - // Shown only over a card Resume would send, so a header never offers to send nothing; - // deleting a blocking returned card shows it again. - const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) - const queuePause = pause ? { reason: pause.reason } : null + const queuedMessages = readPublishedQueuedMessages(journal) + // 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. A restart's pause is never shown: the chat's next turn lifts it. + const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) if ( 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 d411efc42395..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' @@ -15,7 +14,6 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - userSend?: true }, immediate: () => Promise> ): Promise> { 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 300f5eed3e74..5080ea935a56 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,8 +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 past -// them and they follow it, a withdrawn card comes back under it, a crash keeps it, it -// never hides a restart's pause, a restart's holds only cards written before it, and no stored -// pause is ever written. +// 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, 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' @@ -46,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 @@ -106,34 +105,31 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) - it('a card queued after the Stop sends past the cards it holds, which then follow it in order', async () => { + it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { const working = await rig.workingSend() - const first = await queuedDraft('queued before the stop') - const second = await queuedDraft('also queued before the stop') + 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') - // Held cards are skipped, not waited on: the newer card goes; the held ones wait. + // 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([ + { messageId: held, state: 'waiting' }, + { messageId: later, state: 'waiting' } + ]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + expect(await rig.handoff(later)).toBeUndefined() + await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(held), 'held') await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) - await expectHeld('stopped', first, second) - // The card is the person's, so its send is their turn: once it starts, the Stop is over. - expect(await rig.handoff(later)).toMatchObject({ origin: 'client' }) - await rig.settleAccepted(await rig.handoffId(later), 'later') - expect(await rig.queuePause()).toBeNull() - 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()) }) - 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 @@ -141,9 +137,10 @@ 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() + await expectHeld('stopped', first) + await rig.settleAccepted(mail, 'mail') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) }) @@ -163,13 +160,9 @@ 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 } }) - // Resume's drain sends it again under a fresh id, still the person's turn. await eventually(async () => expect(await rig.handoffId(sentId)).not.toBe(handoffId)) - expect((await rig.handoff(sentId))?.origin).toBe('client') }) it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { @@ -188,26 +181,27 @@ describe("Stop's event", () => { }) }) -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') @@ -219,28 +213,8 @@ describe("a Stop never hides a restart's pause", () => { rig.crashRestartHostProcess() await rig.settleAccepted(await rig.handoffId(correction), 'correction') await rig.queuePause() - // That send was the person's turn, so it ended the Stop; it started before the restart, so - // the restart's pause stands. expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) - await expectHeld('restarted', typed) - }) -}) - -describe("a restart's pause", () => { - it("holds only cards written before it: one typed during Orca's own turn sends when that turn ends, and the older card follows once it starts", async () => { - const working = await rig.workingSend() - const before = await queuedDraft('written before the restart') - await rig.restartHostProcess() - await rig.settleAccepted(working, 'a') - // Orca's own turn after the restart, such as its continuation. - const continuation = await mailTurn() - const typed = await queuedDraft('typed during the continuation') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) - await rig.settleAccepted(continuation, 'continuation') - await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) - expect(await rig.handoff(before)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(typed), 'typed') - await eventually(async () => expect(await rig.handoff(before)).toBeDefined()) + await expectHeld(null, typed) }) }) @@ -253,7 +227,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() @@ -273,8 +247,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 59d478cbb40a..616cf3e89704 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 @@ -165,18 +165,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-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index c8fe5d9da5d1..4fa2811472a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -518,8 +518,7 @@ describe('startup restore of chats still in their per-chat files', () => { messageId: 'draft-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - origin: 'client' + hostInstance: 'proc-1' }) expect(journal.importPending).toBe(false) 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 a91ed7c8cbd4..fa2ac365bb8c 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) @@ -155,7 +155,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) @@ -263,7 +263,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) @@ -389,7 +389,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() @@ -429,7 +429,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 dd0acf82b371..ad9ad0ee8048 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 @@ -385,8 +385,8 @@ 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 }, @@ -415,7 +415,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 9a978877888b..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 @@ -322,7 +322,7 @@ describe('a restart between a Stop and its turn end', () => { ) expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) // A host send after the Stop opens its own turn. - const drained = rig.send('drained after the Stop', undefined, { internal: true }) + 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-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 629d7e717903..7f244e6f019e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -125,8 +125,6 @@ export async function performSend( clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem - /** Who asked for the turn; absent on callers that predate it. */ - origin?: 'client' | 'host' } ): Promise> { const existing = ctx.journal 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..ba2d9279a51c 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,8 +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. - const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) + const result = await host.send(structuredCallerFor(context), params) const capabilities = context.clientCapabilities ?? [] if ( !result.ok || 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 b7bbd283fd8c..2da1d5713af4 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 @@ -132,8 +132,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)}`) @@ -348,10 +347,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) })) } @@ -389,7 +387,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 } ) @@ -404,7 +402,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/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 9481dd9008c9..3b35359565f6 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -89,7 +89,7 @@ function waitingDraft( } /** The list over the real controller: cards and header as the host's publication projects them. - * Nothing runs after a Stop, a restart or a /clear. */ + * Nothing runs after a Stop or a /clear. */ function renderHeldQueue( queuedMessages: AgentSessionQueuedMessage[], queuePause: AgentSessionQueuePause | null @@ -288,7 +288,6 @@ describe('NativeChatQueuedMessageList', () => { it.each([ ['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'] ])( @@ -296,10 +295,7 @@ describe('NativeChatQueuedMessageList', () => { (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, { heldBy: pause })], - pause - ) + 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. @@ -318,14 +314,15 @@ describe('NativeChatQueuedMessageList', () => { } ) - it('an older host that names no per-card hold: its queue pause holds every card and heads them', () => { + 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', () => { - renderHeldQueue([waitingDraft('typed-after', 1, { heldBy: null })], { reason: 'stopped' }) + 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 } = renderHeldQueue([], { reason: 'stopped' }) @@ -337,9 +334,9 @@ describe('NativeChatQueuedMessageList', () => { const stopped = { reason: 'stopped' } as const const { container } = renderHeldQueue( [ - waitingDraft('refused', 1, { state: 'returned', returnedReason: null, heldBy: null }), - waitingDraft('behind', 2, { heldBy: stopped }), - waitingDraft('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: stopped }) + waitingDraft('refused', 1, { state: 'returned', returnedReason: null }), + waitingDraft('behind', 2), + waitingDraft('failed', 3, { paused: true, pausedReason: 'send_failed' }) ], stopped ) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index c9d3c2f1d1b4..ade8680fe793 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -80,11 +80,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/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 36562dbd0f98..ee8e3ea34161 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 @@ -12,8 +12,7 @@ import { ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, - queuedMessagesQueuePause, - queuedMessagesResumable + queuedMessagesQueuePause } from './structured-agent-session-queued-cards' function draft( @@ -150,7 +149,7 @@ describe('queued message cards', () => { draft('behind', 4) ], [], - { hasPendingPrompt: true, queuePause: { reason: 'stopped' } } + { hasPendingPrompt: true, queuePaused: true } ) expect(cards.map((card) => card.hold)).toEqual([ 'queue-paused', @@ -163,69 +162,34 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) - it("a host that names each card's hold holds only those cards; one that does not holds every card", () => { - const named = [ - draft('held', 1, { heldBy: { reason: 'stopped' } }), - draft('typed-after', 2, { heldBy: null }) - ] - const paused = { hasPendingPrompt: true, queuePause: { reason: 'restarted' } } as const - // The card queued after the Stop still says it waits for the answer. - expect(projectQueuedMessageCards(named, [], paused).map((card) => card.hold)).toEqual([ - 'queue-paused', - 'awaiting-answer' - ]) - const older = [draft('held', 1), draft('typed-after', 2)] - expect(projectQueuedMessageCards(older, [], paused).map((card) => card.hold)).toEqual([ - 'queue-paused', - 'queue-paused' - ]) - }) - - it('Resume only over a card a pause holds, and only while nothing runs', () => { - const project = (messages: AgentSessionQueuedMessage[]) => - projectQueuedMessageCards(messages, [], { - hasPendingPrompt: false, - queuePause: { reason: 'stopped' } - }) - const held = draft('held', 1, { heldBy: { reason: 'stopped' } }) - expect(queuedMessagesResumable(project([held]), false)).toBe(true) - // Working counts the queue's coming send, which the host names. - expect(queuedMessagesResumable(project([held]), true)).toBe(false) - const failed = draft('failed', 1, { paused: true, pausedReason: 'send_failed', heldBy: null }) - expect(queuedMessagesResumable(project([failed]), false)).toBe(false) - expect(project([held, draft('next', 2, { heldBy: null })]).map((card) => card.hold)).toEqual([ - 'queue-paused', - 'turn' - ]) - const [card] = project([held]) - // Still Steer while nothing runs: the header row, not the card, says it waits. - expect(card && queuedMessageCardSteers(card)).toBe(true) + 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 oldest held card's pause, and none over cards Resume would not send", () => { - const project = (messages: AgentSessionQueuedMessage[]) => - projectQueuedMessageCards(messages, [], { - hasPendingPrompt: false, - queuePause: { reason: 'stopped' } - }) - const restarted = draft('restarted', 1, { heldBy: { reason: 'restarted' } }) - const stopped = draft('stopped', 2, { heldBy: { reason: 'stopped' } }) - expect(queuedMessagesQueuePause(project([restarted, stopped]))).toEqual({ reason: 'restarted' }) - // An older host names no per-card hold: its queue-level pause is the reason. - expect(queuedMessagesQueuePause(project([draft('older', 1)]))).toEqual({ reason: 'stopped' }) + 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, heldBy: null }), - draft('behind', 2, { heldBy: { reason: 'stopped' } }), - draft('failed', 3, { - paused: true, - pausedReason: 'send_failed', - heldBy: { reason: 'stopped' } - }) + draft('returned', 1, { state: 'returned', returnedReason: null }), + draft('behind', 2), + draft('failed', 3, { paused: true, pausedReason: 'send_failed' }) ] - expect(queuedMessagesQueuePause(project(unsendable))).toBeNull() - expect( - queuedMessagesQueuePause(project([draft('typed-after', 1, { heldBy: null })])) - ).toBeNull() + 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() }) it("only the person's own direct send, recorded and not yet accepted, is on its way to lift a pause", () => { 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 18d8aa6ac2ba..49ee3fbc83df 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 @@ -21,8 +21,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** Held by a Stop, restart or /clear: the header row above the cards says why and offers - * Resume, so the card makes no promise of when it sends — not even after an answer. */ + /** The whole queue is paused: the header row says why and offers Resume, so the card makes + * no promise about when it sends — not even after an answer, which does not drain it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' @@ -36,9 +36,6 @@ export type QueuedMessageCard = { text: string state: 'waiting' | 'returned' hold: QueuedMessageCardHold - /** The pause holding a 'queue-paused' card. A string: a newer host may name a reason this build - * does not know. */ - queuePause?: { reason: string } pausedReason?: string returnedReason?: string | null /** The typed fact the returned card's submission settled with; read like its `rejection`. */ @@ -59,7 +56,7 @@ function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string export function projectQueuedMessageCards( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, submissions: readonly AgentJournalSubmission[], - session: { hasPendingPrompt: boolean; queuePause?: AgentSessionQueuePause | null } + session: { hasPendingPrompt: boolean; queuePaused?: boolean } ): QueuedMessageCard[] { const handedOff = handedOffQueuedMessageIds( submissions.filter((submission) => submission.dispatchState !== 'rejected') @@ -69,8 +66,6 @@ export function projectQueuedMessageCards( .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) let behindReturned = false return ordered.map((message) => { - // A host that predates `heldBy` publishes only the queue's pause, which then covers every card. - const heldBy = message.heldBy === undefined ? (session.queuePause ?? null) : message.heldBy const hold: QueuedMessageCardHold = message.state === 'returned' ? 'returned' @@ -78,7 +73,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : heldBy + : session.queuePaused ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' @@ -90,7 +85,6 @@ export function projectQueuedMessageCards( text: queuedMessageCardText(message.body), state: message.state, hold, - ...(hold === 'queue-paused' && heldBy ? { queuePause: { reason: heldBy.reason } } : {}), ...(message.pausedReason !== undefined ? { pausedReason: message.pausedReason } : {}), ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), ...(message.returnedRejection !== undefined @@ -100,18 +94,19 @@ export function projectQueuedMessageCards( }) } -/** The pause the header row names, from the oldest card it holds. A pause over cards Resume would - * not send (returned, held on their own, or behind a returned one) offers nothing to press. */ +/** 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[] -): { reason: string } | null { - return cards.find((card) => card.hold === 'queue-paused')?.queuePause ?? null + cards: readonly QueuedMessageCard[], + queuePause: AgentSessionQueuePause | null +): AgentSessionQueuePause | null { + return cards.some((card) => card.hold === 'queue-paused') ? queuePause : null } /** The person's own message from this composer has reached the host as a direct send and waits * for the agent to accept it. Its turn will lift the queue's pause, so the header row does not - * say paused meanwhile; a refusal settles the entry and the row comes back. An Orca send never - * enters this outbox, and the queue's send of a card goes under a fresh id. */ + * say paused meanwhile; a refusal settles the entry and the row comes back. The queue's send of a + * card goes under a fresh id, so it never counts. */ export function ownDirectSendOnItsWay( outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[] @@ -127,15 +122,6 @@ export function ownDirectSendOnItsWay( return outbox.some((entry) => entry.state !== 'rejected' && pending.has(entry.clientMessageId)) } -/** Whether the composer's Resume would release anything: nothing runs, and a pause holds a card. - * `isWorking` counts the queue's coming send, which the host names. */ -export function queuedMessagesResumable( - cards: readonly QueuedMessageCard[], - isWorking: boolean -): boolean { - return !isWorking && queuedMessagesQueuePause(cards) !== 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 { 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 index b53fec950a70..b6af3bf1d139 100644 --- 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 @@ -23,7 +23,7 @@ export type NativeChatQueueSendConfirm = { count: number /** Delete every card, then send; a failed delete sends nothing. */ clearQueue: () => void - /** Send and keep the cards: the message is the person's turn, so they follow it. */ + /** 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 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 index 8549d1b7862a..e82f60e73393 100644 --- 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 @@ -32,6 +32,7 @@ function renderController( options: { queuePause?: AgentSessionQueuePause | null enabled?: boolean + isWorking?: boolean ownSendOnItsWay?: boolean answer?: (messageId: string) => DeleteAnswer } = {} @@ -48,7 +49,7 @@ function renderController( queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, submissions: [], hasPendingPrompt: false, - isWorking: options.ownSendOnItsWay === true, + isWorking: options.isWorking === true || options.ownSendOnItsWay === true, ownSendOnItsWay: options.ownSendOnItsWay === true, composerScopeKey: undefined, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape. @@ -66,26 +67,35 @@ afterEach(async () => { 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, { heldBy: STOPPED }), - card('typed-after', 2, { heldBy: null }), - card('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: null }) + 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('typed-after', 1, { heldBy: null })] - expect(renderController(unheld).result.current.queueHold).toBeUndefined() - const ownHeld = [card('failed', 1, { paused: true, heldBy: null })] + 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, { heldBy: STOPPED })] + 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("goes, with the paused row, while the person's own message is on its way to lift the pause", () => { - const held = [card('held', 1, { heldBy: STOPPED })] + const held = [card('held', 1)] const { result } = renderController(held, { ownSendOnItsWay: true }) expect(result.current.pause).toBeNull() expect(result.current.queueHold).toBeUndefined() @@ -95,10 +105,7 @@ describe('the held queue a new message asks about', () => { }) it('Clear queue deletes every card shown, and answers true once all are gone', async () => { - const { result, mutate } = renderController([ - card('held', 1, { heldBy: STOPPED }), - card('typed-after', 2, { heldBy: null }) - ]) + const { result, mutate } = renderController([card('held', 1), card('typed-after', 2)]) let cleared: boolean | undefined await act(async () => { cleared = await result.current.queueHold?.clear() @@ -111,10 +118,9 @@ describe('the held queue a new message asks about', () => { }) it('a delete that fails makes Clear queue answer false', async () => { - const { result } = renderController( - [card('held', 1, { heldBy: STOPPED }), card('typed-after', 2, { heldBy: null })], - { answer: (messageId) => (messageId === 'held' ? null : { deleted: true, messageId }) } - ) + 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() 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 87aa65a665e4..8796fd3aad98 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 @@ -89,7 +89,7 @@ afterEach(() => { }) describe('whether Resume is offered', () => { - it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( + 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. @@ -109,6 +109,21 @@ describe('whether Resume is offered', () => { 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' }), @@ -129,24 +144,23 @@ describe('while a turn runs over held cards', () => { }) describe("the header row's pause", () => { - it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( - "names the reason the host holds a card for ('%s')", + 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 heldBy = { reason } as AgentSessionQueuePause - const { result } = renderController({ queuedMessages: [card('held', { heldBy })] }) + 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 over cards no pause holds', () => { + 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', heldBy: null }), - card('returned', { position: 2, state: 'returned', heldBy: null }) + card('failed', { paused: true, pausedReason: 'send_failed' }), + card('returned', { position: 2, state: 'returned' }) ] expect(renderController({ queuedMessages }).result.current.pause).toBeNull() - const typedAfter = [card('typed-after', { heldBy: null })] - expect(renderController({ queuedMessages: typedAfter }).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 () => { 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 c72e9b6d2690..31d834bd750e 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 @@ -21,7 +21,6 @@ import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, queuedMessagesQueuePause, - queuedMessagesResumable, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -44,10 +43,10 @@ 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 no turn runs and the host holds a card Resume would send: the composer offers + /** 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 the header row shows: a new message first asks whether to clear the cards. */ + /** Present while `queueResume` is: a new message first asks whether to clear the cards. */ queueHold: NativeChatQueueHold | undefined } @@ -81,7 +80,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { const { queuePause } = args const cards = useMemo( - () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePause }), + () => + projectQueuedMessageCards(queuedMessages, submissions, { + hasPendingPrompt, + queuePaused: queuePause !== null + }), [hasPendingPrompt, queuePause, queuedMessages, submissions] ) const cardsRef = useRef(cards) @@ -204,17 +207,20 @@ export function useStructuredAgentSessionQueuedMessages(args: { setResuming(false) } }, [mutate]) - const resumable = enabled && queuedMessagesResumable(cards, args.isWorking) - const queueResume = useMemo( - () => (resumable ? { resume, resuming } : undefined), - [resumable, resume, resuming] - ) - const ownSendOnItsWay = args.ownSendOnItsWay === true const pause = useMemo( - () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards)), - [cards, ownSendOnItsWay] + () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards, queuePause)), + [cards, ownSendOnItsWay, 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] + ) + // 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( @@ -226,7 +232,6 @@ export function useStructuredAgentSessionQueuedMessages(args: { [removeCard] ) const count = cards.length - const held = enabled && pause !== null const queueHold = useMemo(() => (held ? { count, clear } : undefined), [held, count, clear]) return { 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 02deb70497e2..963f8b669693 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 @@ -341,7 +341,7 @@ describe('against a capable host', () => { it("hides the paused row while the person's own message is on its way, and shows it again once that send is refused", () => { items = [] queuePause = { reason: 'stopped' } - queuedMessages = [{ ...draft('held'), heldBy: queuePause }] + queuedMessages = [draft('held')] const held = render() expect(held.result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) const sent = createStructuredAgentSessionOutboxEntry({ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2a42c585adde..1ad4750b5777 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18193,7 +18193,6 @@ "pausedSendFailed": "Couldn't send — press Send to retry.", "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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e396da1bd3f8..19b3e821a706 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17853,7 +17853,6 @@ "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", "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", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 38ffb4133073..03d290dbbbb7 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18064,7 +18064,6 @@ "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", "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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c40989a9c4af..eeb2da6cb11c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18000,7 +18000,6 @@ "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", "resume": "再開", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c5da761137e1..fd48d7ea1747 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18000,7 +18000,6 @@ "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", "resume": "재개", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e62fd953cfea..31068c07065f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17965,7 +17965,6 @@ "pausedSendFailed": "无法发送。点按发送以重试。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", "resume": "继续", diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 9613afa8a849..7bd658f740d5 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -437,9 +437,6 @@ export type AgentJournalSubmission = { /** The queued draft this submission hands off; absent for a direct send. Read this, never * 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. */ - origin?: 'client' | 'host' } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index 5b22da35b249..66696d5066ce 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -10,13 +10,12 @@ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED /** 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 = { @@ -46,10 +45,6 @@ export type AgentSessionQueuedMessage = { * marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason - /** The queue-level pause holding this card, or null when none does: a card queued after a Stop - * is not held by it. Absent from a host that predates it; a client then treats every waiting - * card as held while `queuePause` is set. */ - heldBy?: AgentSessionQueuePause | null /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host * wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index b155f9c70649..49c202468b74 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -221,9 +221,8 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. // 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. Each card may carry `heldBy`, the pause holding it or null; when it is absent (an older -// host) a client falls back to `queuePause` for every card. `nextQueuedMessageId` beside the list +// `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's own hold (`paused`) means only that its conversion failed. // The host mechanism lands first; the constant gates the rollout. diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 333ca053c03d..33422421902d 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' } }) From 8252a23ffe3eecf70332601ea2964643410e56d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:07:02 -0700 Subject: [PATCH 21/31] fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too --- ...ed-agent-session-queued-card-holds.test.ts | 64 ++++++++++++++++++- ...tructured-agent-session-queued-messages.ts | 3 +- ...ed-agent-session-queued-pause-lift.test.ts | 8 ++- .../structured-agent-session-queued-pause.ts | 14 ++++ ...ctured-agent-session-queued-publication.ts | 7 +- ...ured-agent-session-queued-stop-row.test.ts | 9 ++- 6 files changed, 96 insertions(+), 9 deletions(-) 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 index 0e23de428b13..351299764905 100644 --- 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 @@ -112,6 +112,8 @@ type ClientView = { 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 } @@ -198,6 +200,7 @@ async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise queuedMessageCardSteers(card)), + holds: cards.map((card) => card.hold), cards: cards.length, nextQueuedMessageId }) @@ -293,7 +296,9 @@ describe('after a restart, nothing sends by itself', () => { header: false, dialog: false, button: 'send', - cards: 2 + cards: 2, + holds: ['turn', 'turn'], + steers: [true, true] }) expect(await rig.drafts()).toEqual([ { messageId: first, state: 'waiting' }, @@ -346,6 +351,63 @@ describe('after a restart, nothing sends by itself', () => { 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, before any turn, never sends by itself, even once the older cards are gone', 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') + expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) + expect(await rig.deleteQueued(second)).toMatchObject({ ok: true, value: { deleted: true } }) + // The agent refuses it: no turn started, so the card written meanwhile still waits. + await rig.settleRejected(message.id, 'turn/start refused') + await expectNothingSent(typed) + expect(derivedPauses()).toEqual(['restarted']) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + const next = rig.send('asked again') + await next.result + await rig.settleAccepted(next.id, 'next') + 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 }) 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 5689b9144e0e..77ff5a72971a 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 @@ -29,6 +29,7 @@ import type { QueuedMessageRow } from '../agent-session-journal/queued-message-t import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionHostInstance, + structuredQueuedCardHostInstance, structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' @@ -252,7 +253,7 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() + hostInstance: structuredQueuedCardHostInstance(ctx.journal) }, ctx.operationReceipt ) 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 154470f2a5af..2ada2ff0f5ae 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 @@ -106,7 +106,9 @@ 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, never shown, lasts until a turn sent since it. await eventually(async () => expect(derivedPauses()).toEqual(['restarted'])) @@ -443,10 +445,10 @@ describe('Resume', () => { await expectPaused(draftId) }) - it("of a Stop from before a restart also lifts the restart's pause", async () => { + 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()).toEqual({ reason: 'stopped' }) + 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 6d29bcf74a1b..97ee20fd6d1e 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 @@ -29,6 +29,20 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ return journal.queuedMessages.pauses(hostInstance) } +/** The process a new card is written for. While a restart's hold is in force (no turn since this + * conversation opened, a card from an earlier process waiting), the earlier process: the card + * joins that hold, so it never sends by itself even once the older cards are gone. */ +export function structuredQueuedCardHostInstance(journal: PauseJournal): string { + const { queuedMessages } = journal + if (queuedMessages.restartEnded()) { + return hostInstance + } + const earlier = queuedMessages + .list() + .find((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) + return earlier?.hostInstance ?? hostInstance +} + /** * 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. 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 18bfa4f91ea5..27475bd4ffca 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 @@ -106,8 +106,11 @@ export function readQueuePublication( const queuedMessages = readPublishedQueuedMessages(journal) // 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. A restart's pause is never shown: the chat's next turn lifts it. - const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + // 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 pause = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) 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 5080ea935a56..79d201b33167 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 @@ -165,7 +165,7 @@ describe("Stop's event", () => { 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') @@ -175,7 +175,12 @@ 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()) }) From 7a61051caf2b6a94a9ce9b6b5a5e67d247d8c278 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:52:40 -0700 Subject: [PATCH 22/31] fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way - The queue stops handing cards off when the host tears down. A card sent during a quit was refused at close, and that refused send withdrew the chat's restart offer, so resuming after the relaunch sent nothing. - The host publishes no pause while a turn sent after it (your message, Steer, or Orca's own) waits for the agent; a refusal shows it again. This replaces the client's own-send check. - A card written after a restart is an ordinary card again: it waits while any card from before the restart still waits. --- .../queued-message-pause.ts | 25 ++++- .../structured-agent-session-host.ts | 1 + ...ed-agent-session-queued-card-holds.test.ts | 105 ++++++++---------- ...ured-agent-session-queued-messages.test.ts | 10 +- ...tructured-agent-session-queued-messages.ts | 14 ++- ...ed-agent-session-queued-pause-lift.test.ts | 11 +- .../structured-agent-session-queued-pause.ts | 19 +--- ...ctured-agent-session-queued-publication.ts | 11 +- ...ructured-agent-session-queued-quit.test.ts | 36 ++++++ ...ured-agent-session-queued-stop-row.test.ts | 3 +- ...ssion-restart-interruption-test-harness.ts | 22 +++- ...d-agent-session-stop-event-entries.test.ts | 3 +- ...tiveChatStructuredSession.test-harness.tsx | 1 - ...uctured-agent-session-queued-cards.test.ts | 27 ----- .../structured-agent-session-queued-cards.ts | 19 ---- ...ent-session-queued-messages.clear.test.tsx | 14 +-- ...tructured-agent-session-queued-messages.ts | 8 +- ...tured-agent-session.queued-gating.test.tsx | 49 +------- .../use-structured-agent-session.ts | 6 +- 19 files changed, 176 insertions(+), 208 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts 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 a4670f6b1493..c6e07234d99f 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 @@ -10,7 +10,10 @@ // 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. -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' @@ -201,3 +204,23 @@ 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: Iterable> +): boolean { + const anchor = pause.since?.sequence ?? 0 + for (const submission of submissions) { + if ( + submission.dispatchState === 'pending' && + submission.acceptedSequence !== undefined && + submission.acceptedSequence > anchor + ) { + return true + } + } + return false +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 8256f90e53cf..85f770dc919d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -251,6 +251,7 @@ export class StructuredAgentSessionHost { // type, and this file has no line budget left for the import. async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.conversationDelivery.dispose() + this.queued.drain.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), idleSweep: this.lifetime, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 351299764905..b169bbe4e5e7 100644 --- 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 @@ -6,19 +6,13 @@ // 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 { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../shared/agent-session-journal-types' +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 type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' -import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../shared/structured-agent-session-draft-hand-off' import { - ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause @@ -118,35 +112,16 @@ type ClientView = { nextQueuedMessageId: string | null } -/** This client's composer sends, settled against the journal as the outbox settles them. */ -type ComposerOutbox = { entries: StructuredAgentSessionOutboxEntry[] } - -/** A composer message on its way, as the outbox holds it once the request went out. */ -function composerSend( - outbox: ComposerOutbox, - text: string -): ReturnType { - const sent = rig.send(text, 'queue-if-active') - outbox.entries.push({ - clientMessageId: sent.id, - sessionId: HOST_TEST_SESSION, - body: hostTestMessage(text), - previewUris: [], - state: 'dispatching', - queuedAt: 1, - lastAttemptAt: 1, - retryAfterUnknownSubmittedAt: null, - sentDelivery: 'queue-if-active' - }) - return sent +/** 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(outbox: ComposerOutbox = { entries: [] }): Promise { +async function watchClient(): Promise { const views: ClientView[] = [] const submissions = new Map() const turns = new Map() - const items = new Map() let queued: AgentSessionQueuedMessage[] = [] let queuePause: AgentSessionQueuePause | null = null let nextQueuedMessageId: string | null = null @@ -162,7 +137,6 @@ async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise { it("the person's own message goes straight to the agent first, then A, then B", async () => { const { first, second } = await restartedIdleWithCards() - const outbox: ComposerOutbox = { entries: [] } - const views = await watchClient(outbox) + const views = await watchClient() // Sent with the composer's queue delivery: nothing ahead may send, so it is not queued. - const message = composerSend(outbox, 'a new instruction') + const message = composerSend('a new instruction') expect(await message.result).toMatchObject({ ok: true, value: { submission: expect.anything() } @@ -386,7 +354,7 @@ describe('after a restart, nothing sends by itself', () => { expect(views.filter((view) => view.header || view.dialog)).toEqual([]) }) - it('a card written after the restart, before any turn, never sends by itself, even once the older cards are gone', async () => { + 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') @@ -395,16 +363,15 @@ describe('after a restart, nothing sends by itself', () => { expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() ) const typed = await queuedDraft('typed while it waits') - expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) - expect(await rig.deleteQueued(second)).toMatchObject({ ok: true, value: { deleted: true } }) - // The agent refuses it: no turn started, so the card written meanwhile still 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(typed) + await expectNothingSent(first, second, typed) expect(derivedPauses()).toEqual(['restarted']) expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - const next = rig.send('asked again') - await next.result - await rig.settleAccepted(next.id, 'next') + 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()) }) @@ -465,29 +432,30 @@ describe('after a restart, nothing sends by itself', () => { 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(outbox: ComposerOutbox) { + 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(outbox) + 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 outbox: ComposerOutbox = { entries: [] } - const { first, second, views } = await heldIdleQueue(outbox) + 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(outbox, 'a new instruction') + 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. - expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + // 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() ) @@ -505,10 +473,31 @@ describe("a message sent over a held queue (the confirmation's Send message)", ( 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 outbox: ComposerOutbox = { entries: [] } - const { first, views } = await heldIdleQueue(outbox) - const message = composerSend(outbox, 'refused') + const { first, views } = await heldIdleQueue() + const message = composerSend('refused') await message.result await eventually(async () => expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() 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 c5d5b3c77696..aa32a663ef4c 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 @@ -447,11 +447,12 @@ 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()) @@ -470,7 +471,8 @@ describe('Stop and Delete', () => { const mail = send('coordinator mail') expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) await new Promise((resolve) => setTimeout(resolve, 250)) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Not shown while the mail awaits the agent, and nothing sends yet. + expect(await rig.queuePause()).toBeNull() expect(await rig.handoff(draftId)).toBeUndefined() await settleAccepted(mail.id, 'b') expect(await rig.queuePause()).toBeNull() 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 77ff5a72971a..3945563757df 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 @@ -29,7 +29,6 @@ import type { QueuedMessageRow } from '../agent-session-journal/queued-message-t import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionHostInstance, - structuredQueuedCardHostInstance, structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' @@ -253,7 +252,7 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredQueuedCardHostInstance(ctx.journal) + hostInstance: structuredAgentSessionHostInstance() }, ctx.operationReceipt ) @@ -284,11 +283,18 @@ export type QueuedMessageDrainDeps = { */ export class StructuredAgentSessionQueuedMessageDrain { private readonly scheduled = new Set() + private disposed = false constructor(private readonly deps: QueuedMessageDrainDeps) {} + /** Host teardown: nothing is handed off from here on. A card sent while the host quits is + * refused at close, and its row would read as the chat having moved on past its restart offer. */ + dispose(): void { + this.disposed = true + } + 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 } @@ -331,7 +337,7 @@ export class StructuredAgentSessionQueuedMessageDrain { } private async step(sessionId: string): Promise { - const session = this.deps.sessions.get(sessionId) + const session = this.disposed ? undefined : this.deps.sessions.get(sessionId) if (!session) { return } 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 2ada2ff0f5ae..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 @@ -84,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') @@ -144,8 +145,12 @@ describe("a Stop's queue pause", () => { 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 sent after the Stop has now started, which ends the pause. await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') 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 97ee20fd6d1e..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 @@ -29,23 +29,10 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ return journal.queuedMessages.pauses(hostInstance) } -/** The process a new card is written for. While a restart's hold is in force (no turn since this - * conversation opened, a card from an earlier process waiting), the earlier process: the card - * joins that hold, so it never sends by itself even once the older cards are gone. */ -export function structuredQueuedCardHostInstance(journal: PauseJournal): string { - const { queuedMessages } = journal - if (queuedMessages.restartEnded()) { - return hostInstance - } - const earlier = queuedMessages - .list() - .find((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) - return earlier?.hostInstance ?? hostInstance -} - /** - * 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. + * 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 27475bd4ffca..eb4dd5ec4fe9 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 @@ -9,7 +9,10 @@ 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' @@ -110,7 +113,11 @@ export function readQueuePublication( // 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 pause = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + 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) 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-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 79d201b33167..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 @@ -139,7 +139,8 @@ describe("Stop's event", () => { await expectHeld('stopped', first) // Orchestration mail sent after the second Stop: once accepted, the queue carries on. const mail = await mailTurn() - await expectHeld('stopped', first) + // 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()) }) 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 616cf3e89704..50134e6b5829 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 @@ -40,6 +40,10 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +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. */ @@ -55,7 +59,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 () => ({ @@ -125,7 +130,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. 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 ad9ad0ee8048..a58273350a47 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 @@ -392,7 +393,7 @@ describe("a person's Stop pause and the Stop events after it", () => { { 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') 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 ffe525948489..e7d11a00da16 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -239,7 +239,6 @@ export function createStructuredSessionMocks() { stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, - turnRunning: mocks.turnId !== null, steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, 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 ee8e3ea34161..2f734e05804b 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 @@ -9,7 +9,6 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { newestSteerableQueuedMessageCard, outboxOutsideQueuedCards, - ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause @@ -192,32 +191,6 @@ describe('queued message cards', () => { expect(queuedMessagesQueuePause(unpaused, null)).toBeNull() }) - it("only the person's own direct send, recorded and not yet accepted, is on its way to lift a pause", () => { - const entry = ( - clientMessageId: string, - state: StructuredAgentSessionOutboxEntry['state'] = 'dispatching' - ): StructuredAgentSessionOutboxEntry => ({ - clientMessageId, - sessionId: 'session-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - previewUris: [], - state, - queuedAt: 1, - lastAttemptAt: 1, - retryAfterUnknownSubmittedAt: null - }) - expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine')])).toBe(true) - // Not yet recorded: it may still become a card. - expect(ownDirectSendOnItsWay([entry('mine')], [])).toBe(false) - // Orca's own send never enters this outbox. - expect(ownDirectSendOnItsWay([], [submission('orca')])).toBe(false) - // The queue's send of a card goes under a fresh id, even for a card this composer queued. - expect(ownDirectSendOnItsWay([entry('card')], [handOff('card')])).toBe(false) - expect(ownDirectSendOnItsWay([entry('mine', 'rejected')], [submission('mine')])).toBe(false) - expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'rejected')])).toBe(false) - expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'unknown')])).toBe(false) - }) - it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index 49ee3fbc83df..bf70de99ef40 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 @@ -103,25 +103,6 @@ export function queuedMessagesQueuePause( return cards.some((card) => card.hold === 'queue-paused') ? queuePause : null } -/** The person's own message from this composer has reached the host as a direct send and waits - * for the agent to accept it. Its turn will lift the queue's pause, so the header row does not - * say paused meanwhile; a refusal settles the entry and the row comes back. The queue's send of a - * card goes under a fresh id, so it never counts. */ -export function ownDirectSendOnItsWay( - outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): boolean { - if (outbox.length === 0) { - return false - } - const pending = new Set( - submissions - .filter((submission) => submission.dispatchState === 'pending') - .map((submission) => submission.clientMessageId) - ) - return outbox.some((entry) => entry.state !== 'rejected' && pending.has(entry.clientMessageId)) -} - /** 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 { 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 index e82f60e73393..ab6d7fd7263c 100644 --- 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 @@ -33,7 +33,6 @@ function renderController( queuePause?: AgentSessionQueuePause | null enabled?: boolean isWorking?: boolean - ownSendOnItsWay?: boolean answer?: (messageId: string) => DeleteAnswer } = {} ) { @@ -49,8 +48,7 @@ function renderController( queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, submissions: [], hasPendingPrompt: false, - isWorking: options.isWorking === true || options.ownSendOnItsWay === true, - ownSendOnItsWay: options.ownSendOnItsWay === true, + 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 @@ -94,16 +92,6 @@ describe('the held queue a new message asks about', () => { expect(renderController(held).result.current.queueHold?.count).toBe(1) }) - it("goes, with the paused row, while the person's own message is on its way to lift the pause", () => { - const held = [card('held', 1)] - const { result } = renderController(held, { ownSendOnItsWay: true }) - expect(result.current.pause).toBeNull() - expect(result.current.queueHold).toBeUndefined() - expect(result.current.queueResume).toBeUndefined() - // The held card is unchanged: only the row says nothing while the turn is on its way. - expect(result.current.cards.map((entry) => entry.hold)).toEqual(['queue-paused']) - }) - 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 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 31d834bd750e..8131aa77c79c 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 @@ -71,8 +71,6 @@ export function useStructuredAgentSessionQueuedMessages(args: { hasPendingPrompt: boolean /** A turn is running, whoever started it, or the queue is about to send its next card. */ isWorking: boolean - /** The person's own message is on its way to start the turn that lifts the pause. */ - ownSendOnItsWay?: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { @@ -207,11 +205,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { setResuming(false) } }, [mutate]) - const ownSendOnItsWay = args.ownSendOnItsWay === true - const pause = useMemo( - () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards, queuePause)), - [cards, ownSendOnItsWay, queuePause] - ) + 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. 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 963f8b669693..4f9be93563d2 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 @@ -9,14 +9,8 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause -} from '../../../../shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -30,8 +24,6 @@ const mocks = vi.hoisted(() => ({ let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined let nextQueuedMessageId: string | null = null -let queuePause: AgentSessionQueuePause | null = null -let submissions: AgentJournalSubmission[] = [] let outboxEntries: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -44,11 +36,11 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions, + submissions: [], status: 'ready', error: null, hasOlder: false, - ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId, queuePause } : {}) + ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId } : {}) }, loadingOlder: false, loadOlder: vi.fn() @@ -137,8 +129,6 @@ beforeEach(() => { items = [RUNNING_TURN] queuedMessages = undefined nextQueuedMessageId = null - queuePause = null - submissions = [] outboxEntries = [] localStorage.clear() clearNativeChatDraftCacheForTests() @@ -338,37 +328,6 @@ describe('against a capable host', () => { expect(refused.result.current).toMatchObject({ isWorking: false, queueSendsNext: false }) }) - it("hides the paused row while the person's own message is on its way, and shows it again once that send is refused", () => { - items = [] - queuePause = { reason: 'stopped' } - queuedMessages = [draft('held')] - const held = render() - expect(held.result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) - const sent = createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'mine', - sessionId: 'session-1', - text: 'new', - attachments: [], - queuedAt: 1 - }) - const recorded: AgentJournalSubmission = { - clientMessageId: 'mine', - fence: 3, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - outboxEntries = [{ ...sent, state: 'dispatching', lastAttemptAt: 1 }] - submissions = [recorded] - expect(render().result.current.queuedMessages.pause).toBeNull() - outboxEntries = [{ ...sent, state: 'rejected', lastAttemptAt: 1 }] - submissions = [{ ...recorded, dispatchState: 'rejected', resolvedAt: 2 }] - expect(render().result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) - }) - 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 351bffcd413a..a2230404b91a 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 @@ -35,10 +35,7 @@ import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-sess import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage' import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' -import { - outboxOutsideQueuedCards, - ownDirectSendOnItsWay -} from './structured-agent-session-queued-cards' +import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' @@ -195,7 +192,6 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, isWorking, - ownSendOnItsWay: ownDirectSendOnItsWay(outbox, transportState.submissions), composerScopeKey, mutate }) From d1170f3e728e0bb1e74c178a239259d01d763561 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:02:33 -0700 Subject: [PATCH 23/31] refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression --- .../queued-message-pause.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) 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 c6e07234d99f..a7b4818607c2 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 @@ -210,17 +210,11 @@ export function resumableQueuePause( * made before a Stop lifts nothing, so it never counts. */ export function queuePauseLiftOnItsWay( pause: DerivedQueuePause, - submissions: Iterable> + submissions: readonly Pick[] ): boolean { const anchor = pause.since?.sequence ?? 0 - for (const submission of submissions) { - if ( - submission.dispatchState === 'pending' && - submission.acceptedSequence !== undefined && - submission.acceptedSequence > anchor - ) { - return true - } - } - return false + return submissions.some( + (submission) => + submission.dispatchState === 'pending' && (submission.acceptedSequence ?? 0) > anchor + ) } From 7c561ee503b1139500c18212de4946381db344b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:38:09 -0700 Subject: [PATCH 24/31] refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue --- .../src/components/native-chat/NativeChatComposer.tsx | 5 ++--- .../NativeChatStructuredSession.test-harness.tsx | 5 +++-- .../native-chat/NativeChatStructuredSession.test.tsx | 6 +++--- .../components/native-chat/NativeChatStructuredSession.tsx | 4 ++-- .../components/native-chat/native-chat-composer-types.ts | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 37ee949d901b..ab54ed848bd9 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -68,8 +68,7 @@ const NativeChatComposerPane = forwardRef + structuredTransport?: Record & { + queueResume?: { resume: () => void; resuming: boolean } + } isWorking?: boolean onStop?: () => void - queueResume?: { resume: () => void; resuming: boolean } }>(), approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 5f9054b010b7..22704f84b77b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -398,13 +398,13 @@ describe('NativeChatStructuredSession', () => { expect(mocks.composerProps?.onStop).toBeUndefined() }) - it('hands the composer Resume only while the queue controller offers it', () => { + 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?.queueResume).toBeUndefined() + expect(mocks.composerProps?.structuredTransport?.queueResume).toBeUndefined() mocks.queuedResumable = true rerender(claudeSessionView('structured-tab-resume', 'session-resume')) act(() => { - mocks.composerProps?.queueResume?.resume() + mocks.composerProps?.structuredTransport?.queueResume?.resume() }) expect(mocks.queuedResume).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index e921a7a5f532..6e0c8822211c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -220,7 +220,8 @@ export function NativeChatStructuredSession( sessionId: props.sessionId, runtimeEnvironmentId: props.target.kind === 'local' ? null : (props.target.environmentId ?? null), - queueHold: controller.queuedMessages.queueHold + queueHold: controller.queuedMessages.queueHold, + queueResume: controller.queuedMessages.queueResume } }, [ controller, @@ -382,7 +383,6 @@ export function NativeChatStructuredSession( isWorking={controller.canStop || controller.queueSendsNext} onStop={controller.canStop ? () => void controller.stop() : undefined} steerQueued={controller.queuedMessages.steerNewest} - queueResume={controller.queuedMessages.queueResume} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> 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 a652f4eef720..7bef1865466c 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 @@ -48,6 +48,9 @@ export type NativeChatStructuredComposerTransport = { 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 = { @@ -93,9 +96,6 @@ export type NativeChatComposerProps = { /** Cmd/Ctrl+Enter from an empty composer: send the newest queued draft now. * False = nothing queued, and the chord falls through to a plain send. */ steerQueued?: () => boolean - /** Present while the host holds the queue and no turn runs: an empty composer's primary - * action becomes Resume, which releases it. */ - queueResume?: NativeChatQueueResume | undefined } /** Launch context prefilled into the TUI input as an unsent draft, plus the two From 439545e12424eef9e8e65200508cc993ff7bb266 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:41:43 -0700 Subject: [PATCH 25/31] fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props - The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as usual. The pending choice records the hold it was asked under; nothing new is stored. - The composer-field Resume test passes main's dropScopeKey/draftScopeKey. - The dialog test expects main's rule: only the sent text leaves the composer. --- ...native-chat-composer-field-resume.test.tsx | 3 +- .../native-chat-queue-send-confirm.test.tsx | 37 ++++++++++++++++++- ...se-native-chat-held-queue-composer-send.ts | 26 +++++++++---- 3 files changed, 55 insertions(+), 11 deletions(-) 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 index d6d315b7b8cd..f496af3e6c12 100644 --- 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 @@ -71,7 +71,8 @@ function TestField({ const textareaRef = useRef(null) return ( { expect(structured.send).toHaveBeenCalledTimes(1) }) - it('text typed meanwhile stays in the composer once the message is accepted', async () => { + 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('start over, and also check the tests') + expect(promptValue(input)).toBe(', and also check the tests') }) it('an unchanged draft is cleared once the message is accepted', async () => { @@ -358,4 +358,37 @@ describe('sending while the queue is held', () => { 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/use-native-chat-held-queue-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts index b5a6d0043140..8ca9f12a55e5 100644 --- 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 @@ -11,6 +11,7 @@ import { 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' type StructuredComposerSend = ( text: string, @@ -33,8 +34,8 @@ export type NativeChatQueueSendConfirm = { type PendingSend = { text: string attachments: readonly NativeChatComposerImageAttachment[] | undefined - count: number - clear: () => Promise + /** 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 } @@ -51,12 +52,14 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured // 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 - }, []) + 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(() => { @@ -79,8 +82,7 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured const asked = { text, attachments, - count: queueHold.count, - clear: queueHold.clear, + hold: queueHold, sentFrom: readNativeChatComposerDraft(args.draftScopeKey) } untakenRef.current = asked @@ -107,7 +109,7 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured clearingRef.current = true void (async () => { try { - if (await taken.clear()) { + 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) } @@ -120,6 +122,14 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured take() }, [take]) - const confirm = pending ? { open, count: pending.count, clearQueue, sendMessage, dismiss } : null + const confirm = pending + ? { + open: open && pending.hold === queueHold, + count: pending.hold.count, + clearQueue, + sendMessage, + dismiss + } + : null return { send, confirm } } From 8342047284ebc920201867a6709145b7641d9cb0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:21:58 -0700 Subject: [PATCH 26/31] fix(native-chat): a message kept after a quit or close waits like every other card, and follows the chat's next turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message Orca accepted but never handed to the agent before a quit, crash or close came back as a card held on its own ("Not sent yet — press Send"): the queue skipped past it, so later cards sent first, and only the person's own Send released it. It is now an ordinary card at the head of the queue that waits, with every card the chat closed with, for the chat's next accepted turn. One rule for a chat that was not running, derived from the journal: when this host first opens a chat (after a restart or crash), or a person closes it, and cards are waiting, a reopen mark is written (a tombstone carrier key, like the Stop and Resume marks). The cards queued before it wait until a turn is accepted or Resume comes after it; nothing sends by itself, and no paused row shows, a Stop's included. The idle sweep's own eviction writes nothing and changes nothing the person sees. A mark that cannot be written leaves the open working and holds from the open itself until the next turn; the next open marks again. A rewind restates the mark. /clear's carried cards also wait unshown. This replaces the host-instance comparison and its adoption write, and the 'kept' hold (stored 'kept' and legacy 'stopped' holds now read as none). --- .../MobileNativeChatQueuedMessages.test.tsx | 3 +- ...le-structured-queued-message-cards.test.ts | 28 +- .../mobile-structured-queued-message-cards.ts | 10 +- ...e-structured-agent-session-queued.test.tsx | 10 +- .../journal-dispatch-settlement.test.ts | 31 +- .../journal-dispatch-settlement.ts | 23 +- .../journal-epoch-replacement.test.ts | 2 +- .../journal-epoch-replacement.ts | 8 +- .../journal-queued-messages.ts | 41 +-- .../journal-row-schema.ts | 14 +- .../journal-stop-and-resume-rows.ts | 28 +- .../journal-store-collaborators.ts | 5 +- .../journal-store-contracts.ts | 6 +- .../agent-session-journal/journal-store.ts | 25 ++ .../journal-unsent-send-hold.test.ts | 26 +- .../journal-unsent-send-hold.ts | 29 +- .../queued-message-holds.ts | 26 +- .../queued-message-pause.test.ts | 258 +++++++++------- .../queued-message-pause.ts | 86 ++++-- .../queued-message-settlement.ts | 2 - .../queued-message-store.test.ts | 36 +-- .../queued-message-stored-row.ts | 10 +- .../queued-message-table.ts | 33 +-- ...red-agent-session-conversation-lifetime.ts | 13 +- ...uctured-agent-session-conversation-open.ts | 17 +- .../structured-agent-session-delivery-loop.ts | 14 +- .../structured-agent-session-host-delivery.ts | 7 +- .../structured-agent-session-host-lifetime.ts | 17 +- ...ed-agent-session-queued-card-holds.test.ts | 18 +- ...tured-agent-session-queued-command.test.ts | 3 +- ...ured-agent-session-queued-messages.test.ts | 22 +- ...tructured-agent-session-queued-messages.ts | 2 +- ...ructured-agent-session-queued-mutations.ts | 19 +- .../structured-agent-session-queued-pause.ts | 88 ++++-- ...ctured-agent-session-queued-publication.ts | 26 +- ...ured-agent-session-queued-stop-row.test.ts | 10 +- .../structured-agent-session-queued-wiring.ts | 8 +- ...ent-session-restore-without-import.test.ts | 5 +- ...d-agent-session-stop-event-binding.test.ts | 12 +- ...d-agent-session-stop-event-entries.test.ts | 4 +- ...red-agent-session-unsent-send-hold.test.ts | 277 ++++++++++++------ .../NativeChatQueuedMessageCard.tsx | 11 +- .../NativeChatQueuedMessageList.test.tsx | 23 +- .../NativeChatQueuedMessageList.tsx | 5 - ...uctured-agent-session-queued-cards.test.ts | 12 +- src/renderer/src/i18n/locales/en.json | 2 - src/renderer/src/i18n/locales/es.json | 2 - src/renderer/src/i18n/locales/fr.json | 2 - src/renderer/src/i18n/locales/ja.json | 2 - src/renderer/src/i18n/locales/ko.json | 2 - src/renderer/src/i18n/locales/zh.json | 2 - .../agent-session-queued-message-wire.ts | 24 +- ...structured-agent-session-coalescer.test.ts | 4 +- ...-session-stop-event-downgrade.unit.test.ts | 17 +- .../kept-card-downgrade.unit.test.ts | 42 ++- 55 files changed, 816 insertions(+), 636 deletions(-) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index c44f34f365d6..463672b8d806 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -261,8 +261,7 @@ 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'], - ['cleared', 'Queue paused after you cleared the conversation'] + ['stopped', 'Queue paused because you interrupted'] ] for (const [reason, label] of rows) { const mounted = await mountPaused({ pause: { reason } }) 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 29153e0a3b9a..51d8cce790d1 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../src/shared/agent-session-wire' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import { mobileQueueHasResumableCard, @@ -102,35 +99,12 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) - // A kept card is held on its own, like a failed one: the drain goes past it. - const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }) - expect(resumable([kept])).toBe(false) - expect(resumable([kept, waiting])).toBe(true) - }) - - // The host kept it unsent across a restart or a close; the cards behind it are not held by it. - it('captions a kept card as not sent yet, and leaves the cards behind it plainly queued', () => { - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }), - draft({ messageId: 'b', position: 2 }) - ], - [], - { pendingPrompt: false } - ) - expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([ - { caption: 'Not sent yet — tap Send to send it', needsAttention: false }, - { caption: null, needsAttention: false } - ]) }) it('words the paused queue by reason, and one this build does not know as a plain pause', () => { expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( 'Queue paused because you interrupted' ) - expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( - 'Queue paused after you cleared the conversation' - ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a reason newer than this build's union, as a newer host would send it. const newer = { reason: 'later_reason' } as never expect(mobileQueuePauseLabel(newer)).toBe('Queue paused') diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index e4d2c123a015..abdf963f0080 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -8,7 +8,6 @@ import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-sessio import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuePause @@ -49,22 +48,17 @@ function returnedCaption( ) } -/** One card's own hold: a failed conversion, or a send the host kept; the queue's pause is the - * list's first row. */ +/** One card's own hold: a failed conversion; the queue's pause is the list's first row. */ function pausedCaption(reason: string | undefined): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return "Couldn't send — tap Send to retry" } - if (reason === QUEUED_MESSAGE_PAUSED_KEPT) { - return 'Not sent yet — tap Send to send it' - } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' } const QUEUE_PAUSE_LABELS: Readonly> = { - stopped: 'Queue paused because you interrupted', - cleared: 'Queue paused after you cleared the conversation' + stopped: 'Queue paused because you interrupted' } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any 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 fe7f7f1eb14a..d59c830b18ed 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 @@ -576,20 +576,20 @@ describe('mobile structured queued messages', () => { listener?.( batchEvent( [ - queuedDraft({ messageId: 'kept-1', paused: true, pausedReason: 'kept' }), + queuedDraft({ messageId: 'kept-1' }), queuedDraft({ messageId: 'behind', position: 2 }) ], [], - { reason: 'restarted' } + null ) ) ) + // Plain waiting cards: the host holds them until the chat's next turn, and shows no row. expect(hook!.queued.cards.map(({ messageId, caption }) => ({ messageId, caption }))).toEqual([ - { messageId: 'kept-1', caption: 'Not sent yet — tap Send to send it' }, + { messageId: 'kept-1', caption: null }, { 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).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts index 7c6deabef407..b952f7672c5a 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -10,34 +10,29 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -function settle(kind: SubmissionRejectionKind, origin?: 'client' | 'host') { - return rejectedDraftSettlement({ - ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }), - origin - }) +function settle(kind: SubmissionRejectionKind) { + return rejectedDraftSettlement( + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) + ) } describe('what a rejection does to the draft it was consumed from', () => { it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { - expect(settle('cancelled')).toEqual({ state: 'waiting', kept: false }) + expect(settle('cancelled')).toEqual({ state: 'waiting' }) expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ - state: 'waiting', - kept: false + state: 'waiting' }) - expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false }) + expect(settle('notDelivered')).toEqual({ state: 'waiting' }) }) - // A Send the person asked for and the host never handed over waits for them; the queue's own - // hand-off, or one from a build that recorded no origin, waits under the queue's pause. - it('a restart or close before hand-over sends it back to waiting, kept only when the person sent it', () => { + // Whoever sent it, it waits under the reopen's pause like every card the chat closed with. + it('a restart or close before hand-over sends it back to waiting, with no hold of its own', () => { for (const kind of ['hostRestarted', 'chatClosed'] as const) { - expect(settle(kind, 'client')).toEqual({ state: 'waiting', kept: true }) - expect(settle(kind, 'host')).toEqual({ state: 'waiting', kept: false }) - expect(settle(kind)).toEqual({ state: 'waiting', kept: false }) + expect(settle(kind)).toEqual({ state: 'waiting' }) } - expect( - rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED, origin: 'client' }) - ).toEqual({ state: 'waiting', kept: true }) + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ + state: 'waiting' + }) }) it('a failure returns the card for the user to act on', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index 6b3e53f36b37..c56e4f47d45d 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -30,27 +30,18 @@ export function consumedSubmissionWasRejected( } /** What a consumed draft becomes when its submission is rejected. */ -export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean } +export type RejectedDraftSettlement = { state: 'returned' | 'waiting' } /** - * Where no one failed the user, the draft goes back to waiting at its own position, under whatever - * pauses the queue: a Stop's, or the restart's, derived from the host instance. A Send the person - * asked for (`origin` client) that a restart or a close cut short is kept (`kept`) until they send - * it again, as the host keeps every message a person sent and it never handed over; the queue's - * own hand-off is not theirs, so it waits as any queued card does: under the restart's pause after - * a restart, and plainly queued after a close in the same process. A returned card would block the drafts - * behind it on a failure that never happened. A failure returns the card with its refusal for the - * user to act on. + * Where no one failed the user, the draft goes back to waiting, under whatever pauses the queue: a + * Stop's, or, when a restart or a close cut it short, the reopen's, so it follows the chat's next + * turn like every card the chat closed with. A returned card would block the drafts behind it on a + * failure that never happened. A failure returns the card with its refusal for the user to act on. */ export function rejectedDraftSettlement( - rejected: Pick & { rejection?: unknown } + rejected: Pick & { rejection?: unknown } ): RejectedDraftSettlement { - const { verdict, kind } = classifyDispatchRejection(rejected) - if (verdict !== null) { - return { state: 'returned' } - } - const cutShort = kind === 'hostRestarted' || kind === 'chatClosed' - return { state: 'waiting', kept: cutShort && rejected.origin === 'client' } + return { state: classifyDispatchRejection(rejected).verdict !== null ? 'returned' : 'waiting' } } /** True when committing this row NEWLY settles the submission to `rejected` — diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 6337b00417bf..36247e2571ff 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -68,7 +68,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, - queuePause: { lifted: false, liveStop: null }, + queuePause: { lifted: false, liveStop: null, reopened: false }, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index da53b2e9a11d..dd56f2ac0f75 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,6 +16,7 @@ import type { JournalLoad } from './journal-open' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { + buildJournalQueueReopenRow, buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' @@ -77,7 +78,7 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } - const { lifted, liveStop } = input.queuePause + const { lifted, liveStop, reopened } = input.queuePause const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) if (lifted) { const row = buildJournalQueueResumeRow(place()) @@ -89,6 +90,11 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + if (reopened) { + const row = buildJournalQueueReopenRow(place()) + applyJournalRow(state, row) + rows.push(row) + } const { sessionId } = input.identity input.database.transaction((db) => { 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 5caccedf53ed..058921e6a8bb 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 @@ -5,7 +5,10 @@ // working status, teardown, or the idle sweep. import type Database from '../../sqlite/sync-database' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS @@ -15,7 +18,7 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' -import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' +import { holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, journalUserStopInForce, @@ -56,8 +59,8 @@ export type JournalQueuedMessagesDeps = { database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState - /** Whether this handle found the row at `sequence` on disk when it opened. */ - wroteBeforeOpen: (sequence: number) => boolean + /** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */ + reopenFloor: () => AgentJournalCursor | null /** The journal's own commit notification. Every standalone draft-table * transaction that changed rows fires it after COMMIT, so a draft or hold * change publishes and wakes the drain through the same path a journal row @@ -159,8 +162,8 @@ export class JournalQueuedMessages { } /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ - pauses(hostInstance: string): DerivedQueuePause[] { - return this.derivePauses(this.list(), hostInstance) + pauses(): DerivedQueuePause[] { + return this.derivePauses(this.list()) } /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ @@ -169,37 +172,17 @@ export class JournalQueuedMessages { return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) } - private derivePauses( - cards: readonly QueuedMessageRow[], - hostInstance: string - ): DerivedQueuePause[] { + private derivePauses(cards: readonly QueuedMessageRow[]): DerivedQueuePause[] { const state = this.deps.state() return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, cards, - hostInstance, - restartEnded: this.restartEnded() + reopenFloor: this.deps.reopenFloor() }) } - /** A turn started since this handle opened, which ends a restart's pause. */ - restartEnded(): boolean { - const latest = this.deps.state().latestAcceptedTurnSequence - return latest > 0 && !this.deps.wroteBeforeOpen(latest) - } - - /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. - * Returns whether anything changed. */ - adopt(hostInstance: string): Promise { - const { sessionId } = this.deps - return this.transact( - (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), - (changed) => changed > 0 - ).then((changed) => changed > 0) - } - /** Inside the caller's journal-row transaction (`journal-unsent-send-hold.ts`): one kept send * becomes a card, and the cards ahead of the queue take the positions given. False when a card * by that id already exists, which then stands. */ @@ -288,7 +271,7 @@ export class JournalQueuedMessages { // pick and this claim (both run on the session's serialized lane, held across the send), so // this guards any pause-relevant row written off that lane from overtaking a held card. const cards = listQueuedMessages(db, this.deps.sessionId) - const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) + const pauses = this.derivePauses(cards) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index d2a95a6db724..54fb4097dbb9 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -95,6 +95,9 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true + /** Present: not a removal but a reopen that found waiting cards (`queued-message-pause.ts`), on + * an id no item ever takes. */ + queueReopen?: true } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, @@ -112,16 +115,21 @@ export type JournalStopEvent = { caller?: string } -/** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ +/** A tombstone that carries a Stop event, a Resume or a reopen mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } + | { queueReopen: NonNullable } ) -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +/** A Stop's event, a Resume or a reopen mark. Any value counts, so a newer build's mark never + * removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + return ( + row.kind === 'tombstone' && + (row.stopEvent !== undefined || row.queueResume !== undefined || row.queueReopen !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index e763af8b2e6c..c70f1bbdaec3 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,12 +1,13 @@ -// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, -// each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). +// The rows a Stop's event, a person's Resume and a reopen that found waiting cards append: +// tombstones of ids no item ever takes, each carrying its record as an extra key +// (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' -/** One id per mark kind; no item ever takes either. */ +/** One id per mark kind; no item ever takes any of them. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'stop-event' @@ -15,6 +16,10 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) +const JOURNAL_QUEUE_REOPEN_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-reopen' +}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -40,6 +45,16 @@ export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow } } +export function buildJournalQueueReopenRow(input: RowPlace): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_REOPEN_ITEM_ID, + revision: 1, + queueReopen: true, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + /** A Stop taking effect now: its event's time is its row's. */ export function journalStopEventRowBuilder( state: () => JournalReducerState, @@ -56,3 +71,10 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } + +export function journalQueueReopenRowBuilder( + state: () => JournalReducerState, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => buildJournalQueueReopenRow({ state: state(), seq, fence, ts }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 395ad50a6ee4..855b76c1c0df 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,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, - host.state().latestAcceptedTurnSequence + host.state().latestAcceptedTurnSequence, + host.journal().queuedMessages.pauses() ), cursor: host.cursor, adopt: host.adopt @@ -87,7 +88,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, state: host.state, - wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), + reopenFloor: () => host.journal().reopenFloor(), committed: host.notifyCommitted }) const rowWriter = new JournalRowWriter({ diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 2d7b56d06b1f..792b00b17d17 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -114,9 +114,9 @@ export type JournalSubmissionConsume = { /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to * waiting belongs to the process that sent it, not the one that first wrote the card. */ hostInstance?: string - /** The queue's own send: refused in the consume's transaction while the queue's pause, as - * this host instance derives it, holds the card. Send-now omits it. */ - yieldsToPause?: { hostInstance: string } + /** The queue's own send: refused in the consume's transaction while the queue's pause holds + * the card. Send-now omits it. */ + yieldsToPause?: true } export type JournalItemAppendInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 9b676cb26b38..8525966ba11c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -57,6 +57,7 @@ import type { import type { JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, + journalQueueReopenRowBuilder, journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' @@ -84,6 +85,7 @@ export class AgentSessionJournal { private state: JournalReducerState private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } + private reopenUnmarked = false private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly rowWriter: JournalRowWriter @@ -159,6 +161,14 @@ export class AgentSessionJournal { ) } + /** Where the reopen's pause begins when this handle could not write its mark: the open itself. + * Null once marked, or when nothing needed one. Per handle, so the next open marks again. */ + reopenFloor(): AgentJournalCursor | null { + return this.reopenUnmarked + ? { epoch: this.openedThrough.epoch, sequence: this.openedThrough.sequence + 1 } + : null + } + async open(): Promise { await this.restore() this.openedThrough = this.cursor() @@ -309,6 +319,21 @@ export class AgentSessionJournal { return this.rowWriter.append(journalQueueResumeRowBuilder(() => this.state, fence)) } + /** This open found waiting cards an earlier handle wrote (`queued-message-pause.ts`). */ + appendQueueReopen(fence: number): Promise { + return this.rowWriter.append(journalQueueReopenRowBuilder(() => this.state, fence)) + } + + /** Marks the reopen when cards wait; a failed write leaves the open itself as the pause's start + * (`reopenFloor`), and still throws. */ + async markQueueReopen(fence: number): Promise { + if (this.queuedMessages.list().some((row) => row.state === 'waiting')) { + this.reopenUnmarked = true + await this.appendQueueReopen(fence) + this.reopenUnmarked = false + } + } + appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { return this.lifecycleBatchAppender.append(input) } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index f559f07db12a..4db821567a8a 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -17,7 +17,6 @@ import { } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' @@ -175,7 +174,7 @@ describe('which sends an earlier host process left unsent are kept', () => { }) expect(reopened.queuedMessages.get(id)).toMatchObject({ state: 'waiting', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST, body: message(`text of ${id}`), fingerprint: fingerprint(message(`text of ${id}`)), @@ -224,14 +223,13 @@ describe('which sends an earlier host process left unsent are kept', () => { } }) - // Only a Send the person asked for comes back kept; the queue's own hand-off waits under the - // restart's pause, where it stood. + // Whoever sent it, the card comes back with no hold of its own, to wait under the reopen's pause. it.each([ - { by: 'Send now', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, - { by: 'the queue', origin: 'host' as const, holdReason: null } + { by: 'Send now', origin: 'client' as const }, + { by: 'the queue', origin: 'host' as const } ])( 'a card’s own hand-off by $by returns its card, and makes no second one', - async ({ origin, holdReason }) => { + async ({ origin }) => { const journal = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'card', @@ -258,7 +256,7 @@ describe('which sends an earlier host process left unsent are kept', () => { expect(journal.queuedMessages.get('card')).toMatchObject({ state: 'waiting', consumedAs: null, - holdReason + holdReason: null }) expect(journal.queuedMessages.get('handoff')).toBeNull() } @@ -382,7 +380,14 @@ describe('where kept sends go in the queue', () => { }) // That run kept A, at a stale place behind C, then died before B. await interrupted.resolveDispatch( - { clientMessageId: 'A', state: 'rejected', ...HOST_RESTARTED, fence: 0, recovered: true }, + { + clientMessageId: 'A', + state: 'rejected', + ...HOST_RESTARTED, + fence: 0, + recovered: true, + keptAsQueuedMessageId: 'A' + }, (db) => { interrupted.queuedMessages.holdInTransaction(db, { card: { @@ -391,7 +396,6 @@ describe('where kept sends go in the queue', () => { fingerprint: fingerprint(message('text of A')), hostInstance: HOST, source: USER_MESSAGE_SOURCE, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch: 'epoch-1', sequence: interrupted.submission('A')!.acceptedSequence! @@ -419,7 +423,7 @@ describe('a close of the chat', () => { expect(cardOrder(journal)).toEqual(['person']) expect(journal.queuedMessages.get('person')).toMatchObject({ - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST }) for (const id of ['person', 'mail']) { diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index f08d8bb05c4c..3c3de5bf980a 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -1,9 +1,10 @@ // What becomes of a send the host accepted and can no longer hand over: an earlier host process // quit or crashed first (settled at the next open), or the chat is closing. Either way the agent // provably never got it. A person's message (typed, or a launch's first prompt) is kept as a card -// at the head of the queue, held until the person sends, edits or deletes it (`kept`). Everything -// else is rejected as before, because something else re-derives it or the person re-runs it. The -// submission itself is always rejected, so it is never handed over twice. +// at the head of the queue, an ordinary one: like every card the chat closed with, it waits for the +// chat's next turn (`queued-message-pause.ts`). Everything else is rejected as before, because +// something else re-derives it or the person re-runs it. The submission itself is always rejected, +// so it is never handed over twice. import type { AgentJournalItemBody, @@ -12,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -61,12 +61,13 @@ export function unsentSendKeptAsCard( * both. This batch's cards, and the card of a Send the person asked for, go to the head of the * queue in the order they were accepted, behind the cards an earlier settlement kept; the queue's * own hand-off returns its card where it stood. Throws once every row was - * tried when one of them could not be written at all: that row stays queued. + * tried when one of them could not be written at all: that row stays queued. Resolves whether it + * settled any send. */ export async function holdUnsentSends( journal: AgentSessionJournal, input: { fence: number; hostInstance: string; hold: UnsentSendHold } -): Promise { +): Promise { const { hold } = input const unsent = journal .submissions() @@ -79,7 +80,7 @@ export async function holdUnsentSends( ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { - return + return false } const { epoch } = journal.cursor() const kept = unsent.map((submission) => ({ @@ -123,7 +124,6 @@ export async function holdUnsentSends( fields: { body: card.body } }), hostInstance: input.hostInstance, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, // Only a person's send is kept. source: USER_MESSAGE_SOURCE, queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, @@ -166,6 +166,7 @@ export async function holdUnsentSends( if (failures.length > 0) { throw new AggregateError(failures, 'settling unsent sends failed') } + return true } /** Where each card of the batch goes: right before every other card, the cards an earlier @@ -177,8 +178,18 @@ function headOfQueuePositions( batch: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] ): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { const cards = journal.queuedMessages.list() + const keptIds = new Set( + journal.submissions().flatMap((entry) => entry.keptAsQueuedMessageId ?? []) + ) + const { epoch } = journal.cursor() + // Kept by an earlier settlement: its rejection names it, or, past an epoch that dropped that + // row, it was queued in an earlier epoch, before anything this one accepted. const earlier = cards - .filter((card) => card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) + .filter( + (card) => + card.state === 'waiting' && + (keptIds.has(card.messageId) || (card.queuedAt !== null && card.queuedAt.epoch !== epoch)) + ) .map((card) => card.messageId) const placed: QueuedMessagePositionMove[] = [] for (const { submission, body } of batch) { diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts index 05867436e12e..c3f90590c117 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,7 +1,7 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A -// Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); -// a per-draft hold is a conversion that failed, or a send the host kept -// (`QueuedMessageHoldReason`), which an explicit Send releases. +// Stop, a /clear or a reopen pauses the queue instead (`queued-message-pause.ts`, +// derived); a per-draft hold is a conversion that failed (`QueuedMessageHoldReason`), +// which an explicit Send releases. import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' @@ -27,23 +27,3 @@ export function holdQueuedMessages( } return held } - -/** Ends a restart's pause: waiting rows another host instance wrote are adopted - * into this one, the same fact the pause is derived from, so no second copy - * exists. Also clears a per-row 'stopped' hold an earlier build of the queue - * wrote, which this build only ever lifts. Returns how many rows it changed. */ -export function adoptQueuedMessages( - db: Database.Database, - input: { sessionId: string; hostInstance: string } -): number { - return Number( - db - .prepare( - `UPDATE queued_messages - SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END - WHERE session_id = ? AND state = 'waiting' - AND (host_instance <> ? OR hold_reason = 'stopped')` - ) - .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 - ) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 41a428f50afa..89754a066451 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 @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -29,7 +29,6 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { @@ -98,12 +97,12 @@ function userStop(journal: AgentSessionJournal) { } function reason(journal: AgentSessionJournal): string | null { - return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null + return journal.queuedMessages.pauses()[0]?.reason ?? null } /** Each card, and whether a pause in force holds it. */ function held(journal: AgentSessionJournal): [string, boolean][] { - const pauses = journal.queuedMessages.pauses(HOST) + const pauses = journal.queuedMessages.pauses() return journal.queuedMessages .list() .filter((card) => card.state === 'waiting') @@ -309,7 +308,7 @@ describe("the queue's pause, derived from the journal", () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - ...(automatic ? { yieldsToPause: { hostInstance: HOST } } : {}) + ...(automatic ? { yieldsToPause: true as const } : {}) } ) await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -391,7 +390,7 @@ describe("the queue's pause, derived from the journal", () => { await turn(journal, 'typed') await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) - expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() }) @@ -461,7 +460,7 @@ describe('which cards a pause holds', () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - yieldsToPause: { hostInstance: HOST } + yieldsToPause: true } ) ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -481,91 +480,149 @@ describe('which cards a pause holds', () => { }) }) -describe("a restart's pause", () => { - it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { - const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-restart', - body: message('written before the restart'), - fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0', - source: { kind: 'user' } - }) - expect(reason(journal)).toBe('restarted') - await queueDraft(journal, 'draft-legacy') - await queueDraft(journal, 'draft-failed') - await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) - const db = new Database(journalDatabasePath(root)) - db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( - 'draft-legacy' - ) - db.close() - journal.queuedMessages.invalidate() - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) +describe("a reopen's pause", () => { + /** This handle closes and the next one opens and marks it, as a quit, a crash or a chat close + * leaves it; `failMark` makes the mark's write fail. */ + async function reopen( + journal: AgentSessionJournal, + failMark = false + ): Promise { + await journal.close() + const reopened = await open() + if (failMark) { + vi.spyOn(reopened, 'appendQueueReopen').mockRejectedValueOnce(new Error('disk full')) + await expect(reopened.markQueueReopen(0)).rejects.toThrow('disk full') + } else { + await reopened.markQueueReopen(0) + } + return reopened + } + + it('holds the cards queued before the reopen, never one queued after its mark', async () => { + let journal = await open() + await queueDraft(journal, 'before') expect(reason(journal)).toBeNull() - expect( - journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) - ).toEqual([ - ['draft-restart', HOST, null], - ['draft-legacy', HOST, null], - ['draft-failed', HOST, 'send_failed'] + journal = await reopen(journal) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] ]) + expect(reason(journal)).toBe('restarted') }) - it('adoption and Resume keep a kept card held: only its own Send, Edit or Delete releases it', async () => { - const journal = await open() - const kept = await journal.queuedMessages.insert({ - messageId: 'kept', - body: message('kept across a restart'), - fingerprint: 'fp-kept', - hostInstance: 'proc-0', - source: { kind: 'user' }, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) - expect(journal.queuedMessages.get('kept')).toMatchObject({ - hostInstance: HOST, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) + it.each([ + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'carry-on')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])('%s after the reopen lifts it', async (_name, lift) => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await lift(journal) + expect(reason(journal)).toBeNull() }) - it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - const revision = journal.queuedMessages.revision() - expect(await journal.queuedMessages.adopt(HOST)).toBe(false) - expect(journal.queuedMessages.revision()).toBe(revision) + it('a turn the agent has not accepted yet lifts nothing', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await turn(journal, 'carry-on', false) + expect(reason(journal)).toBe('restarted') + }) + + it('a reopen with no waiting card writes nothing', async () => { + let journal = await open() + await turn(journal, 'earlier') + const before = journal.cursor() + journal = await reopen(journal) + expect(journal.cursor()).toEqual(before) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([['after', false]]) + }) + + it('a second reopen before any turn still holds them; one after a turn holds them again', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + // Still waiting (here, nothing drained it): the chat closed with it again, so it waits again. + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + }) + + // The open could not write its mark: the open itself is the boundary, so nothing sends by + // itself; the next turn or Resume lifts it, and the next open marks again. + it('without a mark the open itself is where the pause begins', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal, true) + expect(held(journal)).toEqual([['before', true]]) + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + }) + + it("reads an earlier build's stored 'kept' and 'stopped' holds as none; send_failed stays", async () => { + let journal = await open() + for (const id of ['was-kept', 'was-stopped', 'failed']) { + await queueDraft(journal, id) + } + await journal.queuedMessages.hold({ messageIds: ['failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + for (const [id, stored] of [ + ['was-kept', 'kept'], + ['was-stopped', 'stopped'] + ]) { + db.prepare('UPDATE queued_messages SET hold_reason = ? WHERE message_id = ?').run(stored, id) + } + db.close() + journal = await reopen(journal) + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.holdReason])).toEqual([ + ['was-kept', null], + ['was-stopped', null], + ['failed', 'send_failed'] + ]) + expect(held(journal)).toEqual([ + ['was-kept', true], + ['was-stopped', true], + ['failed', false] + ]) }) }) describe('which cards the pauses in force hold', () => { type Card = Parameters[1] & { messageId: string } - const DEAD = 'proc-0' function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } - const base = { state: 'waiting', holdReason: null, hostInstance: HOST, carriedFrom: null } + const base = { state: 'waiting', holdReason: null, carriedFrom: null } return { messageId, ...base, queuedAt, ...fields } } - /** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */ - function pausesOver(cards: readonly Card[], stopped = 5) { + /** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark + * it (0: nothing to mark); no turn, Resume or reopen mark since. */ + function pausesOver(cards: readonly Card[], stopped = 5, opened = 0) { return deriveQueuePauses({ epoch: 'epoch-1', marks: { latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, - resumedSequence: 0 + resumedSequence: 0, + reopenedSequence: 0 }, latestAcceptedTurnSequence: 0, cards, - hostInstance: HOST, - restartEnded: false + reopenFloor: opened ? { epoch: 'epoch-1', sequence: opened + 1 } : null }) } - function holding(cards: readonly Card[], stopped?: number): [string, string | null][] { - const pauses = pausesOver(cards, stopped) + function holding( + cards: readonly Card[], + stopped?: number, + opened?: number + ): [string, string | null][] { + const pauses = pausesOver(cards, stopped, opened) return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) } @@ -583,64 +640,51 @@ describe('which cards the pauses in force hold', () => { ]) }) - it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { - const after = card('after', 5, { hostInstance: DEAD }) - expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) - expect(holding([after])).toEqual([['after', 'restarted']]) - expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBeNull() - expect(resumableQueuePause(pausesOver([after]), [after])?.reason).toBe('restarted') - // Written by this process, nothing holds it: a card queued after a Stop sends normally. - const live = card('after', 5) - expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) + it("a Stop that holds nothing never hides a reopen's: a card from before the open waits", () => { + const after = card('after', 5) + expect(pausesOver([after], 5, 6).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) + expect(holding([after], 5, 6)).toEqual([['after', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([after], 5, 6), [after])).toBeNull() + // Queued since the open, nothing holds it: a card queued after a Stop sends normally. + expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBe(after) }) it("a card names the first pause holding it, and the header names the first held card's", () => { - const cards = [ - card('before', 3, { hostInstance: DEAD }), - card('after', 5, { hostInstance: DEAD }) - ] - expect(holding(cards)).toEqual([ + const cards = [card('before', 3), card('after', 5)] + expect(holding(cards, 5, 6)).toEqual([ ['before', 'stopped'], ['after', 'restarted'] ]) - expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') + expect(resumableQueuePause(pausesOver(cards, 5, 6), cards)?.reason).toBe('stopped') }) - // Held on its own, as a card whose send failed: the queue goes past it, and Resume is offered - // over the cards a pause holds behind it. - it('a kept card is skipped like a send_failed one; the cards behind it still send', () => { - const behind = card('behind', 2) - const kept = [card('kept', 1, { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] - expect(holding(kept, 0)).toEqual([ - ['kept', null], - ['behind', null] + // R13-1: a message Orca kept after a quit is an ordinary card, in order, under the reopen's pause. + it('a card from before the open waits in order, and so do the cards behind it', () => { + const cards = [card('kept', 1), card('behind', 2)] + expect(holding(cards, 0, 4)).toEqual([ + ['kept', 'restarted'], + ['behind', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) - expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() - const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] - expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() - expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') - }) - - // A dead process's card held on its own waits for its own Send, so it pauses no other card. - it('a card held on its own from a dead process starts no restart pause', () => { - for (const holdReason of [QUEUED_MESSAGE_PAUSED_KEPT, 'send_failed']) { - const cards = [card('held', 1, { hostInstance: DEAD, holdReason }), card('live', 2)] - expect(pausesOver(cards, 0)).toEqual([]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('live') - } + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() + }) + + // A card held on its own waits for its own Send, so it starts no reopen pause for the others. + it('a card held on its own from before the open starts no reopen pause', () => { + const cards = [card('held', 1, { holdReason: 'send_failed' }), card('live', 5)] + expect(pausesOver(cards, 0, 4)).toEqual([]) + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)?.messageId).toBe('live') }) - it("a /clear's pause that holds nothing never hides a restart's", () => { + it("a /clear's pause that holds nothing never hides a reopen's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), - card('typed', 2, { hostInstance: DEAD }) + card('typed', 2) ] - expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) - expect(holding(cards, 0)).toEqual([ + expect(pausesOver(cards, 0, 4).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect(holding(cards, 0, 4)).toEqual([ ['carried', null], ['typed', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)).toBeNull() + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() }) }) 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 9adff0e5bd3f..34fc06971ffa 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -5,12 +5,14 @@ // 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. +// - 'restarted': a card queued before this conversation last opened waits — Orca quit or +// crashed, or the chat closed, while it waited — and no turn or Resume has happened since the +// open. The open marks itself with a row when it finds waiting cards (`queueReopen`), so a card +// queued after it is never mistaken for one from before. Never published: 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 card held on its own (`hold_reason`, a failed conversion) is outside every pause: only an +// action on it releases it. import type { AgentJournalCursor, @@ -24,29 +26,32 @@ export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ export type JournalStopSettle = { settling: boolean; turnId?: string } -/** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ +/** The latest Stop event, whatever its reason, the latest Resume row and the latest reopen mark, + * folded by the reducer. */ export type JournalQueuePauseMarks = { latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number + /** 0 when none. */ + reopenedSequence: number } export type DerivedQueuePause = { reason: QueuePauseReason - /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + /** Where a Stop's or a reopen's pause began: a card queued at or after it is newer. Null for + * /clear's, which holds the cards it carried. */ since: AgentJournalCursor | null } type QueueCard = { state: string holdReason: string | null - hostInstance: string carriedFrom: string | null queuedAt: AgentJournalCursor | null } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { latestStop: null, resumedSequence: 0 } + return { latestStop: null, resumedSequence: 0, reopenedSequence: 0 } } /** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ @@ -70,6 +75,8 @@ export function foldJournalQueuePauseMark( marks.latestStop = { sequence: row.seq, event: row.stopEvent } } else if (row.queueResume === true) { marks.resumedSequence = row.seq + } else if (row.queueReopen === true) { + marks.reopenedSequence = row.seq } } @@ -94,20 +101,23 @@ export function journalUserStopInForce( } /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a - * turn or a Resume happened), then the Stop still in force, in that order so the lift - * never ends the Stop. */ + * turn or a Resume happened), then the Stop still in force, then the reopen's pause still holding + * a card, in that order so the lift never ends either. */ export type JournalQueuePauseRestatement = { lifted: boolean liveStop: JournalStopEvent | null + reopened: boolean } export function journalQueuePauseRestatement( marks: JournalQueuePauseMarks, - latestAcceptedTurnSequence: number + latestAcceptedTurnSequence: number, + pauses: readonly DerivedQueuePause[] ): JournalQueuePauseRestatement { return { lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null + liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null, + reopened: pauses.some((pause) => pause.reason === 'restarted') } } @@ -118,9 +128,8 @@ export function deriveQueuePauses(input: { marks: JournalQueuePauseMarks latestAcceptedTurnSequence: number cards: readonly QueueCard[] - hostInstance: string - /** A turn started since this conversation opened. */ - restartEnded: boolean + /** Where the reopen's pause begins when this handle could not mark it; null otherwise. */ + reopenFloor: AgentJournalCursor | null }): DerivedQueuePause[] { const { epoch, marks, latestAcceptedTurnSequence } = input const pauses: DerivedQueuePause[] = [] @@ -133,21 +142,42 @@ export function deriveQueuePauses(input: { 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. - const foreign = waiting.some( - (card) => card.holdReason === null && card.hostInstance !== input.hostInstance - ) - if (!input.restartEnded && foreign) { - // The process that wrote a card is gone: every card waits, whenever it was written. - pauses.push({ reason: 'restarted', since: null }) + const reopened = reopenPause(input) + if ( + reopened && + waiting.some((card) => card.holdReason === null && queuedBefore(reopened, card)) + ) { + pauses.push(reopened) } return pauses } -/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a - * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build - * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ -function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { +/** Where the latest reopen began: its mark, or, when this handle could not write one, the open + * itself, so a failed write holds a little more and never sends anything by itself. A floor from + * another epoch is not this one's (a rewind restates the mark). Null once a turn or a Resume came + * after it. */ +function reopenPause(input: { + epoch: string + marks: JournalQueuePauseMarks + latestAcceptedTurnSequence: number + reopenFloor: AgentJournalCursor | null +}): DerivedQueuePause | null { + const { epoch, marks, reopenFloor } = input + const floor = reopenFloor?.epoch === epoch ? reopenFloor.sequence : 0 + const sequence = Math.max(marks.reopenedSequence, floor) + if (sequence === 0) { + return null + } + if (Math.max(input.latestAcceptedTurnSequence, marks.resumedSequence) >= sequence) { + return null + } + return { reason: 'restarted', since: { epoch, sequence } } +} + +/** Queued before the pause began: for /clear, a card it carried. For a Stop or a reopen, a card + * queued before its row; one from another epoch (before a rewind) or from a build that recorded + * no position counts as before. A withdrawn steer keeps its position, so is held. */ +function queuedBefore(pause: DerivedQueuePause, card: QueueCard): boolean { if (pause.reason === 'cleared') { return card.carriedFrom !== null } @@ -171,7 +201,7 @@ export function queuePauseHolding( return undefined } // 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)) + return pauses.find((pause) => queuedBefore(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 029bdb74e6f3..9c30bf4311dd 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -58,7 +58,6 @@ export function settleOwedQueuedMessages( consumedRef, reason: submission?.reason ?? null, rejection: submission?.rejection, - origin: submission?.origin, now: input.now }) settled += changed ? 1 : 0 @@ -124,7 +123,6 @@ export function settleQueuedMessagesForRow( consumedRef: row.clientMessageId, reason: row.reason, rejection: row.rejection, - origin: submission?.origin, now: input.now }) return changed + (settled ? 1 : 0) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 264d17b572a1..7d284b48827a 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -13,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' @@ -408,11 +407,11 @@ describe('returned transition (D1/N4)', () => { }) it.each([ - { by: 'the queue', origin: 'host' as const, holdReason: null }, - { by: 'the person', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + { by: 'the queue', origin: 'host' as const }, + { by: 'the person', origin: 'client' as const } ])( 'a restart between $by’s consume and handover sends the draft back to waiting', - async ({ origin, holdReason }) => { + async ({ origin }) => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1', { origin }) @@ -422,11 +421,10 @@ describe('returned transition (D1/N4)', () => { await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => journal.wroteBeforeOpen(submission.acceptedSequence) ) - // The queue's own hand-off waits under the restart's pause, derived from the row's host - // instance; a Send the person asked for waits for them, kept. + // Whoever sent it, it waits with no hold of its own, under the reopen's pause. expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason, + holdReason: null, hostInstance: 'proc-1', consumedAs: null, returnedReason: null @@ -583,23 +581,15 @@ describe('open-time repair and retention', () => { } }) - // The repair reaches the live hook's answer from the stored rejection and who asked for it. + // The repair reaches the live hook's answer from the stored rejection. it.each([ - { - origin: 'client' as const, - cause: 'hostRestarted' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'hostRestarted' as const, holdReason: null }, - { - origin: 'client' as const, - cause: 'chatClosed' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'chatClosed' as const, holdReason: null } + { origin: 'client' as const, cause: 'hostRestarted' as const }, + { origin: 'host' as const, cause: 'hostRestarted' as const }, + { origin: 'client' as const, cause: 'chatClosed' as const }, + { origin: 'host' as const, cause: 'chatClosed' as const } ])( - 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, holdReason $holdReason', - async ({ origin, cause, holdReason }) => { + 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, back to waiting', + async ({ origin, cause }) => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1', { origin }) @@ -617,7 +607,7 @@ describe('open-time repair and retention', () => { journal = await open() expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason, + holdReason: null, consumedAs: null }) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index f5a10c42073f..489579ed7316 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -60,7 +60,7 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul createdAt: record.created_at, hostInstance: record.host_instance, state, - holdReason: record.hold_reason, + holdReason: storedHoldReason(record.hold_reason), returnedReason: record.returned_reason, returnedRejection: storedRejection(record.returned_rejection), settledAt: record.settled_at, @@ -75,6 +75,14 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } } +/** Holds an earlier build stored that this one derives instead: a kept send (`kept`) waits under + * the reopen's pause, a Stop's (`stopped`) under the Stop's. */ +const QUEUED_MESSAGE_RETIRED_HOLD_REASONS: ReadonlySet = new Set(['kept', 'stopped']) + +function storedHoldReason(stored: string | null): string | null { + return stored !== null && QUEUED_MESSAGE_RETIRED_HOLD_REASONS.has(stored) ? null : stored +} + function storedSource(json: string | null): AgentSessionMessageSource { let stored: unknown = null try { diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index d68341888a6f..5caca68dfd27 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -13,10 +13,7 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../shared/agent-session-queued-message-wire' +import type { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' import { serializeAgentSessionMessageSource, type AgentSessionMessageSource @@ -26,14 +23,12 @@ import { readStoredQueuedMessageRow } from './queued-message-stored-row' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' -/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`), or it - * is a send the host accepted and kept across a restart or a close (`kept`). Stored on the row, so - * it survives handle eviction and restart; a wire marker (it publishes as `pausedReason`). A Stop - * or a restart pauses the whole queue instead. A reader treats an unknown stored value as a plain - * hold. */ -export type QueuedMessageHoldReason = - | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED - | typeof QUEUED_MESSAGE_PAUSED_KEPT +/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`). Stored + * on the row, so it survives handle eviction and restart; a wire marker (it publishes as + * `pausedReason`). A Stop, a /clear or a reopen pauses the queue instead. A reader treats an + * unknown stored value as a plain hold, and the holds earlier builds wrote that this one derives + * (`QUEUED_MESSAGE_RETIRED_HOLD_REASONS`) as none. */ +export type QueuedMessageHoldReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and * what the published list shows. Pending/unknown/accepted deliveries and @@ -240,8 +235,8 @@ export function withdrawQueuedMessages( * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused * again still settles while a late duplicate of an earlier refusal matches - * nothing. A draft back to waiting keeps its position and carries no refusal, held as `kept` - * when the settlement says so; its spent submissions stay findable by their `queuedMessageId` link. + * nothing. A draft back to waiting keeps its position and carries no refusal; its spent + * submissions stay findable by their `queuedMessageId` link. */ export function settleRejectedQueuedMessage( db: Database.Database, @@ -250,8 +245,6 @@ export function settleRejectedQueuedMessage( consumedRef: string reason: string | null rejection: UnreadAgentSessionFailureFact | undefined - /** Who asked for the rejected hand-off (`AgentJournalSubmission.origin`). */ - origin: 'client' | 'host' | undefined now: number } ): boolean { @@ -261,15 +254,11 @@ export function settleRejectedQueuedMessage( ? db .prepare( `UPDATE queued_messages - SET state = 'waiting', hold_reason = ?, consumed_as = NULL, + SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` ) - .run( - settlement.kept ? QUEUED_MESSAGE_PAUSED_KEPT : null, - input.sessionId, - input.consumedRef - ) + .run(input.sessionId, input.consumedRef) : db .prepare( `UPDATE queued_messages diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 4033c70de1c3..fec48d66d396 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -25,6 +25,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { forgetStructuredQueueOpen } from './structured-agent-session-queued-pause' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -153,14 +154,20 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is - * still queued will not be sent; a person's message stays as a held card. */ + * still queued waits for the chat's next turn; a person's unsent message stays as a card. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { - // Settled before the stop, so no start delivers it. - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal) + // Settled and marked before the stop, so no start delivers it. + await holdClosedStructuredAgentSessionSends( + { ...deps(), sessions }, + sessionId, + session.journal + ) + } else { + forgetStructuredQueueOpen(sessions, sessionId) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 9c556916a39b..194173797112 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -6,8 +6,9 @@ // crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A // row an earlier process accepted and never handed over (it quit or crashed first) is settled here -// too, before any reader, command or child sees it: a person's message is kept as a held card, the -// rest rejected (`journal-unsent-send-hold.ts`). Nothing here starts a provider child. +// too, before any reader, command or child sees it: a person's message is kept as a card, the rest +// rejected (`journal-unsent-send-hold.ts`). The cards then wait for the chat's next turn +// (`queued-message-pause.ts`). Nothing here starts a provider child. import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,7 +24,10 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueFirstOpen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -73,6 +77,13 @@ export async function openStructuredAgentSessionConversation( return null } const opened = await openStructuredAgentSessionConversationJournal(context.deps, record, options) + // After the open made its leftovers cards, so the mark follows every card it found. + await markStructuredQueueFirstOpen( + { sessions: context.sessions, logger: context.deps.logger }, + sessionId, + opened.session.journal, + record.lease.runtimeFence + ) await context.adoptOpened(sessionId, opened) return opened.session } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 3d5d37050439..afadc950bce6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -21,7 +21,10 @@ import { } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { @@ -163,11 +166,16 @@ export class StructuredAgentSessionDeliveryLoop { // The open already did, unless its write failed: a row an earlier handle wrote is never handed // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then // fails, which rejects every queued send, this process's own too. - await holdUnsentSends(session.journal, { - fence: this.deps.conversationFence(sessionId), + const fence = this.deps.conversationFence(sessionId) + const settled = await holdUnsentSends(session.journal, { + fence, hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'hostRestarted' } }) + if (settled) { + // The open's settle failed, so it marked nothing for the cards this made. + await markStructuredQueueReopen(this.deps, sessionId, session.journal, fence) + } if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index d1bb1cfa7944..3b2b2926065e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -68,7 +68,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { holdClosed: async (sessionId, which) => { const session = sessions.get(sessionId) return session - ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, which) + ? holdClosedStructuredAgentSessionSends( + { ...deps, sessions }, + sessionId, + session.journal, + which + ) : true }, failureTextContext: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index fda7bc9c27e6..748706e95133 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -24,7 +24,10 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -58,10 +61,12 @@ export type StructuredAgentSessionLifetimeContext = { type ConversationCloseDeps = Pick & { store: Pick + /** The host's open conversations (`markStructuredQueueReopen`). */ + sessions: ReadonlyMap } /** What is still queued when the chat closes will not be handed over: a person's message is kept - * as a held card, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the + * as a card that waits for the chat's next turn, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the * next open settles what it left. `which` narrows it to the messages a close that did not complete * closed. Best effort, so a close never waits on it: resolves false when it failed, reported and * never thrown. */ @@ -71,8 +76,9 @@ export async function holdClosedStructuredAgentSessionSends( journal: StructuredAgentSessionHostSession['journal'], which?: (submission: AgentJournalSubmission) => boolean ): Promise { - return holdUnsentSends(journal, { - fence: structuredAgentSessionConversationFence(deps.store, sessionId), + const fence = structuredAgentSessionConversationFence(deps.store, sessionId) + const held = await holdUnsentSends(journal, { + fence, hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'chatClosed', ...(which ? { which } : {}) } }).then( @@ -86,6 +92,9 @@ export async function holdClosedStructuredAgentSessionSends( return false } ) + // The chat stopped running: nothing it closed with sends by itself, even if it stays open. + await markStructuredQueueReopen(deps, sessionId, journal, fence) + return held } /** 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 index ac3956bcc0d0..de0f07be9afc 100644 --- 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 @@ -21,7 +21,7 @@ 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 { AgentSessionJournal } from '../agent-session-journal/journal-store' import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { readQueuePublication, @@ -516,11 +516,17 @@ describe("the queue's next card on a history page", () => { 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. + // The Resume row is written; the Resume itself, 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 held = new Promise((resolve) => (release = resolve)) + const write = AgentSessionJournal.prototype.appendQueueResume + const resume = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueResume') + .mockImplementationOnce(async function (this: AgentSessionJournal, fence: number) { + const cursor = await write.call(this, fence) + await held + return cursor + }) const resumed = rig.resume() try { await eventually(async () => { @@ -529,7 +535,7 @@ describe("the queue's next card on a history page", () => { }) } finally { release() - adopt.mockRestore() + resume.mockRestore() } expect(await resumed).toMatchObject({ ok: true, value: { resumed: true } }) }) 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 dda3d2112cd3..7ab0fcfc1f9f 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 @@ -152,7 +152,8 @@ describe('/clear', () => { { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first in the new chat') const fields = { body, delivery: 'queue-if-active' as const } 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 b5759f03595c..5308682e8183 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 @@ -421,10 +421,12 @@ describe('Stop and Delete', () => { await stop() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await settleAccepted(working, 'a') - // Evict the handle and reopen (the history read opens the conversation at - // rest): the pause is derived from what the journal holds, so nothing drains. + // Close and reopen it (the history read opens it at rest): the derived pause still holds, so + // nothing drains; a close hides the row, as nothing runs there until its next turn. await host.close(SESSION, 'evict') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + const reopened = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(structuredQueuePauses(reopened).map((pause) => pause.reason)).toContain('stopped') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() // Send-now overrides the pause — the user acting is a release. @@ -615,15 +617,15 @@ describe('/clear', () => { if (!replacementId) { throw new Error('expected a replacement session') } - // The source's cards are spent tombstones; the replacement shows them on a - // queue paused by the clear — not "because you interrupted" — until the user - // acts: Resume, or their next send starting its turn. + // The source's cards are spent tombstones; the replacement shows them as plain waiting cards, + // held by the clear with no row (nothing runs there), until the user acts: Resume, or their + // next send starting its turn. expect(await drafts()).toHaveLength(0) expect(await drafts(replacementId)).toEqual([ { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() // Paused from before the first carried card lands: the idle replacement auto-sends nothing. await new Promise((resolve) => setTimeout(resolve, 250)) expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) @@ -663,14 +665,14 @@ describe('/clear', () => { ]) }) - it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + it("the replacement's unshown 'cleared' pause lifts through Resume exactly like a Stop's", async () => { const [firstId] = await pausedDrafts() const cleared = await clear(hostTestOperationId()) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined if (!replacementId) { throw new Error('expected a replacement session') } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() const resumed = await host.queuedMessagesResume(CALLER, { envelope: envelope( {}, @@ -733,7 +735,7 @@ describe('/clear', () => { // The refusal belonged to the source's submissions; on the replacement the // text is simply a waiting draft again, behind the replacement's pause. expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() expect(await drafts()).toHaveLength(0) }) 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 7a80a14ca609..df4e768370e5 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 @@ -387,7 +387,7 @@ export class StructuredAgentSessionQueuedMessageDrain { expect: 'waiting', settledByOp: null, hostInstance: structuredAgentSessionHostInstance(), - yieldsToPause: { hostInstance: structuredAgentSessionHostInstance() } + yieldsToPause: true } ) } catch (error) { 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 7429b69ac87d..4343aefbc550 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -7,13 +7,12 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionQueuedMessageDeleteResult, - type AgentSessionQueuedMessagesResumeResult, - type AgentSessionSendResult +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' @@ -119,11 +118,7 @@ export async function carryQueuedMessagesToClearReplacement( fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId, - source: row.source, - // A kept send stays held there too: no later message may release it. - ...(row.holdReason === QUEUED_MESSAGE_PAUSED_KEPT - ? { holdReason: QUEUED_MESSAGE_PAUSED_KEPT } - : {}) + source: row.source }) } await withdrawQueuedMessagesForOperation(ctx.journal, { 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 6722313d0394..85433b616301 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 @@ -1,6 +1,6 @@ // Whether the queue is paused, and why — derived from the journal and the cards -// (`queued-message-pause.ts`), never stored. A Stop's event and a Resume are journal -// rows; an explicit Resume lifts any pause. +// (`queued-message-pause.ts`), never stored. A Stop's event, a Resume and a reopen that found +// waiting cards are journal rows; an explicit Resume lifts any pause. import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -8,9 +8,25 @@ import type { DerivedQueuePause } from '../agent-session-journal/queued-message- import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own - * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another - * instance pauses the queue rather than auto-sending after a restart. */ + * `runtimeId` (`orca-runtime-runtime-id.ts`), stamped on the cards this process writes or hands + * off. No pause reads it: an older build holds every card another instance wrote. */ let hostInstance = randomUUID() +/** Per host, keyed by its open-conversations map: the conversations it has opened and marked (or + * found nothing to mark). A later open is the idle sweep's eviction coming back, which the person + * never saw, so it marks nothing. A failed mark leaves its conversation out, so the next open + * tries again. In memory, so a new host process marks again. */ +const openedHere = new WeakMap>() + +type HostConversations = ReadonlyMap + +function openedBy(sessions: HostConversations): Set { + let opened = openedHere.get(sessions) + if (!opened) { + opened = new Set() + openedHere.set(sessions, opened) + } + return opened +} export function structuredAgentSessionHostInstance(): string { return hostInstance @@ -26,38 +42,54 @@ type PauseJournal = Pick /** The queue's pauses in force, derived; none when the queue sends on its own. */ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[] { - return journal.queuedMessages.pauses(hostInstance) + return journal.queuedMessages.pauses() } -/** - * 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( +/** The mark of a chat that stopped running with cards waiting — Orca quit or crashed, or the chat + * was closed — so they wait for its next turn (`queued-message-pause.ts`). Bookkeeping, so a + * failure is reported and never thrown; the pause then starts at the open itself, holding no + * less. Resolves whether it marked, or found nothing to mark. */ +export async function markStructuredQueueReopen( + host: { sessions: HostConversations; logger: StructuredAgentSessionLogger }, sessionId: string, - journal: PauseJournal, - logger: StructuredAgentSessionLogger -): Promise { + journal: Pick, + fence: number +): Promise { + const { logger } = host try { - const { queuedMessages } = journal - const restarted = queuedMessages - .list() - .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) - if (restarted && queuedMessages.restartEnded()) { - await queuedMessages.adopt(hostInstance) - } + await journal.markQueueReopen(fence) + return true } catch (error) { - logger.warn("adopting a restart's queued cards after a started turn failed", { - scope: 'queue-pause-adoption', + // Unmarked, so this host's next open of the chat marks it. + openedBy(host.sessions).delete(sessionId) + logger.warn('marking a reopened queue failed', { + scope: 'queue-reopen-mark', sessionId, error }) + return false } } -/** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's - * rows. Returns whether the queue was paused. */ +/** The open's mark, on this host's first open of the chat only (or the first since a close of it + * that had no conversation open): a restart or a crash ended it running elsewhere, while an idle + * eviction here changes nothing the person sees. */ +export async function markStructuredQueueFirstOpen( + host: { sessions: HostConversations; logger: StructuredAgentSessionLogger }, + sessionId: string, + journal: Pick, + fence: number +): Promise { + const opened = openedBy(host.sessions) + if ( + !opened.has(sessionId) && + (await markStructuredQueueReopen(host, sessionId, journal, fence)) + ) { + opened.add(sessionId) + } +} + +/** Resume: a journal row that ends every pause. Returns whether the queue was paused. */ export async function resumeStructuredQueue( journal: Pick, fence: number @@ -66,6 +98,10 @@ export async function resumeStructuredQueue( return false } await journal.appendQueueResume(fence) - await journal.queuedMessages.adopt(hostInstance) return true } + +/** A close of a chat this host has no conversation open for: its next open marks it. */ +export function forgetStructuredQueueOpen(sessions: HostConversations, sessionId: string): void { + openedBy(sessions).delete(sessionId) +} 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 af1b7e567b3f..2fafb1e1f627 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -4,7 +4,6 @@ // streams from re-sending it. They ride together: a client never sees one without the others. import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuedMessagePausedReason, @@ -42,9 +41,8 @@ export function structuredQueueSendGate( }) } -/** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion, or a send the host kept); a Stop or a /clear pauses the queue, published once - * beside it. */ +/** Waiting and returned rows only. `paused` is a per-card hold (a failed conversion); a person's + * Stop pauses the queue, published once beside it. */ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { @@ -72,7 +70,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess function isPublishedPausedReason( reason: string | null ): reason is AgentSessionQueuedMessagePausedReason { - return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED || reason === QUEUED_MESSAGE_PAUSED_KEPT + return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED } type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } @@ -114,18 +112,20 @@ export function readQueuePublication( gate: QueueSendGate ): QueuePublication { const queuedMessages = readPublishedQueuedMessages(journal) - // 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. + // Read per emit: the pause also turns on submissions (a turn starting). Only a person's Stop is + // shown, and 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 /clear, a restart or a close nothing + // is shown, a Stop's included: nothing runs in the chat, its next turn lifts every pause, and the + // cards read as plain waiting cards until then. An idle eviction marks nothing, so it hides + // nothing. const pauses = structuredQueuePauses(journal) - const restarted = pauses.some((pause) => pause.reason === 'restarted') - const resumable = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + const silent = pauses.some((pause) => pause.reason !== 'stopped') + const resumable = silent ? 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 + // The test narrows the type. + const queuePause = pause?.reason === 'stopped' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) if ( 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 4e45ab4a6cc3..83b75d4282f0 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 @@ -243,7 +243,11 @@ describe("a /clear's carried cards", () => { if (!replacementId) { throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held there, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() + expect(structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)).toEqual([ + 'cleared' + ]) // Idle there, so the person's send goes straight out rather than queueing. const text = hostTestMessage('hi') const person = rig.host.send(QUEUED_RIG_CALLER, { @@ -296,7 +300,9 @@ describe('no stored pause', () => { ...fields }) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) + expect( + replacementId && structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason) + ).toEqual(['cleared']) expect(pauseTables()).toBe(0) }) }) 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 af7aee44bc98..a5ddfeb64748 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 @@ -9,7 +9,6 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' -import { adoptEndedRestartPause } from './structured-agent-session-queued-pause' import { deleteQueuedStructuredAgentMessage, resumeStructuredAgentQueue, @@ -39,14 +38,9 @@ export function wireStructuredAgentSessionQueuedMessages( return { 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 turn started. */ + * settlements are all commits, and each re-derives the drain's gates. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) - const journal = sessions.get(sessionId)?.journal - if (journal) { - void adoptEndedRestartPause(sessionId, journal, context().deps.logger) - } drain.schedule(sessionId) }, queuedMessageSend: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index d53d06f35a00..e81ec745d0e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -4,7 +4,6 @@ // every chat had its own file, and opens no file it does not restore. import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -385,9 +384,7 @@ describe('startup restore of chats still in their per-chat files', () => { const cards = hostDb() .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') .all('chat-kept') - expect(cards).toEqual([ - { message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' } - ]) + expect(cards).toEqual([{ message_id: 'client-chat-kept', hold_reason: null, state: 'waiting' }]) }) it('lets other work run while it reads a large per-chat file', async () => { 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 8d46b360b9fd..ef182c3ca13f 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 @@ -3,6 +3,7 @@ // does a Stop of a start that never landed; a card it held, which Resume releases, and anything // sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { @@ -128,6 +129,13 @@ async function mailTurn(): Promise { } /** The host evicts the chat; the Stop events as its provider close finds them. */ +/** The person's Stop still pauses the reopened chat. The close hides its row, as every close of a + * chat does: nothing runs there until its next turn. */ +async function stillStopped(): Promise { + expect(await rig.queuePause()).toBeNull() + return structuredQueuePauses(journal()).some((pause) => pause.reason === 'stopped') +} + async function evictedAt(): Promise { let atClose: JournalStopEvent[] = [] rig.closeSession.mockImplementationOnce(async () => { @@ -369,7 +377,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() } ) @@ -384,7 +392,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() }) 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 aa9ff4d041d5..34d2badde3c4 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 @@ -261,7 +261,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the await rig.host.close(HOST_TEST_SESSION, 'evict') expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The close hides the row, as every close of a chat does; the Stop still pauses. + expect(await rig.queuePause()).toBeNull() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toContain('stopped') }, 20_000 ) 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 d5323ac5055f..f9abc82a10aa 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 @@ -1,13 +1,12 @@ // A message the host accepted while the agent was starting, then Orca quit or crashed, or the chat -// closed, before handing it over: it is kept as a held card at the head of the queue, never sent on -// its own, and released only by the person's own Send, Edit or Delete on it. Against the real host, -// store and journal. +// closed, before handing it over: it is kept as an ordinary card at the head of the queue, never +// sent on its own; like every card the chat closed with, it follows the chat's next accepted turn +// (or a Resume, Send now, Edit or Delete). Against the real host, store and journal. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' @@ -43,7 +42,8 @@ const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRes const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) -const KEPT = { state: 'waiting', paused: true } +/** A kept send: an ordinary waiting card, with no hold of its own. */ +const KEPT = { state: 'waiting' } let rig: QueuedMessageTestRig @@ -102,33 +102,36 @@ describe('a message accepted while the agent starts, then Orca stops', () => { it.each([ { how: 'quit', restart: () => rig.quitRestartHostProcess() }, { how: 'crash', restart: async () => rig.crashRestartHostProcess() } - ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { - const id = await acceptWhileStarting(sendRequest('hello after restart')) - await restart() + ])( + 'is a waiting card after a $how, and its submission is rejected unseen', + async ({ restart }) => { + const id = await acceptWhileStarting(sendRequest('hello after restart')) + await restart() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - // Held on its own: no queue-wide pause, so no Resume to press. - expect(await rig.queuePause()).toBeNull() - expect(await rig.submission(id)).toMatchObject({ - dispatchState: 'rejected', - ...HOST_RESTARTED, - origin: 'client', - source: { kind: 'user' } - }) - const [card] = journal().queuedMessages.list() - expect(card).toMatchObject({ - messageId: id, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, - hostInstance: structuredAgentSessionHostInstance(), - body: hostTestMessage('hello after restart'), - queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } - }) - // Nothing is sent on its own, however many times the chat reopens. - await rig.host.close(SESSION, 'evict') - rig.crashRestartHostProcess() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(rig.dispatch).not.toHaveBeenCalled() - }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // Nothing runs in the chat, so no paused row: it waits for the chat's next turn. + expect(await rig.queuePause()).toBeNull() + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + origin: 'client', + source: { kind: 'user' } + }) + const [card] = journal().queuedMessages.list() + expect(card).toMatchObject({ + messageId: id, + holdReason: null, + hostInstance: structuredAgentSessionHostInstance(), + body: hostTestMessage('hello after restart'), + queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } + }) + // Nothing is sent on its own, however many times the chat reopens. + await rig.host.close(SESSION, 'evict') + rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { const id = await acceptWhileStarting(sendRequest('kept through quit')) @@ -152,33 +155,32 @@ describe('a message accepted while the agent starts, then Orca stops', () => { }) }) -describe('only an action on the card releases a kept card', () => { - it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { +// R13-1: a kept send waits in order, like every card the chat closed with, and follows the +// chat's next accepted turn; no "press Send" step. +describe('the next accepted turn releases a kept card', () => { + it('a later message runs first, then the card follows it in order', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await rig.quitRestartHostProcess() - // The person types the same words again, as one who cannot see cards would. - const retyped = rig.send('the kept words') - await retyped.result + const next = rig.send('a new request') + await next.result await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() ) - await rig.settleAccepted(retyped.id, 'retyped') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - - await rig.resume() - await new Promise((resolve) => setTimeout(resolve, 100)) expect(await rig.handoff(id)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await rig.settleAccepted(next.id, 'next') + await eventually(() => expect(dispatchedTexts()).toEqual(['a new request', 'the kept words'])) + expect(await rig.handoff(id)).toMatchObject({ origin: 'host' }) + }) - expect(await rig.sendNow(id)).toMatchObject({ ok: true }) - const handoff = await rig.handoff(id) - expect(handoff?.clientMessageId).not.toBe(id) - expect(handoff).toMatchObject({ origin: 'client' }) - await eventually(() => expect(dispatchedTexts()).toEqual(['the kept words', 'the kept words'])) - expect(await rig.drafts()).toEqual([]) + it('a Resume sends it, and nothing sends it before', async () => { + const id = await acceptWhileStarting(sendRequest('resume me')) + await rig.quitRestartHostProcess() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(dispatchedTexts()).toEqual(['resume me'])) + expect(await rig.handoff(id)).toBeDefined() }) it('Send now sends it at once, and Delete removes it', async () => { @@ -244,7 +246,7 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.drafts()).toEqual([]) }) - it('a Stop on the reopened chat leaves it kept: the open settles it before the Stop runs', async () => { + it('a Stop on the reopened chat leaves its card waiting: the open settles it before the Stop runs', async () => { const id = await acceptWhileStarting(sendRequest('survives a stop')) await rig.crashRestartHostProcess() @@ -254,26 +256,33 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) }) - // A Send now cut short by a quit hands the card back kept, so a retyped copy still goes alone. - // After a second restart the kept card is another process's, yet it pauses nothing else. - it('a later restart leaves the cards queued after it unpaused', async () => { + // Another restart before any turn: the card still waits, and the first turn there releases it + // ahead of the cards queued behind that turn. + it('a second restart still holds it; the next turn sends it before the cards queued after', async () => { const id = await acceptWhileStarting(sendRequest('kept twice over')) await rig.quitRestartHostProcess() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) rig.crashRestartHostProcess() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() const working = await rig.workingSend() const later = rig.send('queued after the restart', 'queue-if-active') expect(await later.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'kept twice over'])) + await rig.settleAccepted(await rig.handoffId(id), 'kept') await eventually(() => - expect(dispatchedTexts()).toEqual(['work on this', 'queued after the restart']) + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'kept twice over', + 'queued after the restart' + ]) ) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) }) - it('stays kept when its own Send is cut short by another quit', async () => { + it('its own Send cut short by another quit brings it back at the head, waiting', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await rig.quitRestartHostProcess() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) @@ -292,16 +301,9 @@ describe('only an action on the card releases a kept card', () => { await rig.quitRestartHostProcess() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - const retyped = rig.send('the kept words') - await retyped.result - await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() - ) - await rig.settleAccepted(retyped.id, 'retyped') + expect(journal().queuedMessages.get(id)?.holdReason).toBeNull() await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() }) }) @@ -408,8 +410,8 @@ describe('the queue at a quit', () => { } ) - // The card the person pushed ahead waits for their own Send; Resume sends the rest. - it('Send now on an ordinary card, cut short by a quit, returns it kept while Resume sends the rest', async () => { + // The card the person pushed ahead comes back at the head; Resume sends it, then the rest. + it('Send now on an ordinary card, cut short by a quit, returns it to the head; Resume sends both in order', async () => { await compactRunning() const first = rig.send('first queued', 'queue-if-active') await first.result @@ -426,9 +428,9 @@ describe('the queue at a quit', () => { ]) expect(await rig.queuePause()).toBeNull() await rig.resume() - await eventually(() => expect(dispatchedTexts()).toEqual(['first queued'])) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts()).toEqual([{ messageId: pushed.id, ...KEPT }]) + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead'])) + await rig.settleAccepted(await rig.handoffId(pushed.id), 'pushed') + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead', 'first queued'])) }) }) @@ -436,7 +438,7 @@ describe('the queue at a quit', () => { // chat, and the chat can be reopened from its history. describe('a message accepted while the agent starts, then the chat closes', () => { it.each(['user-close', 'evict'] as const)( - 'after a %s it is a held card the reopened chat shows, rejected as closed', + 'after a %s it is a waiting card the reopened chat shows, rejected as closed', async (cause) => { const id = await acceptWhileStarting(sendRequest('kept at close')) await rig.host.close(SESSION, cause) @@ -459,9 +461,9 @@ describe('a message accepted while the agent starts, then the chat closes', () = .join('|') expect(texts).not.toContain('kept at close') expect(page.page.queuedMessages?.[0]).toMatchObject({ - body: hostTestMessage('kept at close'), - pausedReason: QUEUED_MESSAGE_PAUSED_KEPT + body: hostTestMessage('kept at close') }) + expect(page.page.queuedMessages?.[0]).not.toHaveProperty('paused') expect(rig.dispatch).not.toHaveBeenCalled() } ) @@ -470,7 +472,7 @@ describe('a message accepted while the agent starts, then the chat closes', () = // The desktop's outbox keeps an unconfirmed send and sends it again, under the same id, once the // app is back. That replay meets the card the open made from the same send. describe('the same send arriving again after the restart', () => { - it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { + it('a client that never asked to queue is answered from its own record: one card, still waiting, nothing sent', async () => { const request = sendRequest('sent again by the outbox') const id = await acceptWhileStarting(request) await rig.quitRestartHostProcess() @@ -492,16 +494,17 @@ describe('the same send arriving again after the restart', () => { await new Promise((resolve) => setTimeout(resolve, 100)) expect(rig.dispatch).not.toHaveBeenCalled() - // Retry on such a client sends the words under a new id: delivered once, the card still held. + // Retry on such a client sends the words under a new id, and the card follows that turn, as + // every card the chat closed with does: the same words twice, by the one rule. const retried = rig.send('sent again by the outbox') await retried.result await eventually(async () => expect((await rig.submission(retried.id))?.handedOverAt).toBeDefined() ) await rig.settleAccepted(retried.id, 'retried') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['sent again by the outbox']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await eventually(() => + expect(dispatchedTexts()).toEqual(['sent again by the outbox', 'sent again by the outbox']) + ) }) it('a client that asked to queue is told the host holds it', async () => { @@ -582,8 +585,8 @@ describe('what is not kept', () => { }) }) -describe('/clear carries a kept card still held', () => { - it('a turn in the new conversation never releases it', async () => { +describe('/clear carries a kept card', () => { + it('the first turn in the new conversation releases it, after that turn', async () => { const id = await acceptWhileStarting(sendRequest('carried through clear')) await rig.quitRestartHostProcess() expect(await rig.drafts()).toHaveLength(1) @@ -597,8 +600,8 @@ describe('/clear carries a kept card still held', () => { throw new Error('expected a replacement session') } expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(carried?.queuedMessages.list()[0]?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + // Nothing runs in the fresh conversation, so no paused row. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first turn after the clear') const turn = await rig.host.send(QUEUED_RIG_CALLER, { @@ -628,9 +631,111 @@ describe('/clear carries a kept card still held', () => { clientMessageId: turnId, providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } }) + await eventually(() => + expect(dispatchedTexts()).toEqual(['first turn after the clear', 'carried through clear']) + ) + }) +}) + +// One rule for a chat that was not running: a card queued while it ran drains as its turn ends, +// and a card it closed with waits for its next accepted turn, written down by the reopen's mark. +describe("the reopen's mark", () => { + it('a card queued mid-turn drains when that turn ends, with no reopen and no mark', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const queued = rig.send('queued mid-turn', 'queue-if-active') + expect(await queued.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'queued mid-turn'])) + expect(marks).not.toHaveBeenCalled() + }) + + it.each(['user-close', 'evict'] as const)( + 'a %s and a reopen in the same process: the card waits for the next turn', + async (cause) => { + const working = await rig.workingSend() + const queued = rig.send('queued before the close', 'queue-if-active') + await queued.result + await rig.host.close(SESSION, cause) + expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(dispatchedTexts()).toEqual(['work on this']) + void working + + const next = rig.send('after the reopen') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'after the reopen', + 'queued before the close' + ]) + ) + } + ) + + // An idle eviction is Orca's own act: the person sees the same rows, and nothing new is held. + it('an idle-sweep eviction of a stopped chat writes nothing, and its Stop row stays', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ idleSweep: { idleMs: 0, intervalMs: 60 * 60 * 1000 } }) + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const held = rig.send('held by the Stop', 'queue-if-active') + await held.result + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + + expect(rig.closeSession).toHaveBeenCalledTimes(1) + // Its handle was only a cache, so it closed; the read below reopens it. + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(held.id)).toBeUndefined() + expect(marks).not.toHaveBeenCalled() + }) + + it('an idle chat with no waiting card is closed and reopened without writing anything', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + await rig.settleAccepted(working, 'working') + const rows = (await rig.host.journalSnapshot(SESSION)).cursor + await rig.host.close(SESSION, 'evict') + expect(await rig.drafts()).toEqual([]) + expect((await rig.host.journalSnapshot(SESSION)).cursor).toEqual(rows) + expect(marks).not.toHaveBeenCalled() + }) + + // Bookkeeping never gates the person: the open goes on, and the open itself is where the pause + // begins, so nothing sends by itself; the next turn releases the card, and the next open marks. + it('an open whose mark cannot be written still opens, and still holds the card', async () => { + const id = await acceptWhileStarting(sendRequest('kept, mark failed')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const marks = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + .mockRejectedValueOnce(new Error('disk full')) + await rig.quitRestartHostProcess() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(marks).toHaveBeenCalledOnce() await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - expect(dispatchedTexts()).toEqual(['first turn after the clear']) + expect(rig.dispatch).not.toHaveBeenCalled() + + const next = rig.send('a new request') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual(['a new request', 'kept, mark failed']) + ) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index baa1fa1be4c7..c22891381fc7 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -21,10 +21,7 @@ import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/st import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../../shared/agent-session-wire' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' import { queuedMessageCardSteers, @@ -66,12 +63,6 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null "Couldn't send — press Send to retry." ) } - if (card.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT) { - return translate( - 'components.native-chat.queuedMessages.pausedKept', - 'Not sent yet — press Send to send it.' - ) - } return translate('components.native-chat.queuedMessages.paused', 'Paused') case 'behind-returned': return translate( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 299ecb24ca9d..4dd74093c1f5 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -290,7 +290,6 @@ describe('NativeChatQueuedMessageList', () => { 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", @@ -511,22 +510,6 @@ describe('NativeChatQueuedMessageList', () => { expect(screen.getAllByRole('button', { name: 'Send' }).length).toBeGreaterThan(0) }) - // A message the host kept after a restart or a close is not a mid-turn steer: on an idle chat - // its action is plainly Send, and its caption says it was never sent. - it('a kept card says it was not sent yet and offers Send, with no Resume over it', async () => { - renderList( - // 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.') - expect(row?.textContent).not.toContain('kept') - fireEvent.focus(screen.getByRole('button', { name: 'Send' })) - expect((await screen.findAllByText('Send this message now')).length).toBeGreaterThan(0) - expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - }) - it('an absent or unknown pause marker reads as a plain pause, never raw', () => { renderList( controller([ @@ -566,11 +549,7 @@ describe('NativeChatQueuedMessageList', () => { ])( 'offers Turn off queueing and the send chord only when the host queues sends ($queueCapable)', async ({ queueCapable, offered }) => { - const owner = controller( - [card({ messageId: 'kept', hold: 'paused', pausedReason: 'kept' })], - null, - queueCapable - ) + const owner = controller([card({ messageId: 'kept', hold: 'paused' })], null, queueCapable) renderList(owner) fireEvent.focus(screen.getByRole('button', { name: 'Send' })) const hint = await screen.findAllByText('Send this message now') diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 17a3563d6513..babb577f3334 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -84,11 +84,6 @@ function queuePauseText(pause: { reason: string }): string { 'components.native-chat.queuedMessages.queuePausedStopped', 'Queue paused because you interrupted' ) - case 'cleared': - return translate( - 'components.native-chat.queuedMessages.queuePausedCleared', - 'Queue paused after you cleared the conversation' - ) default: return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') } 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 e2be4a65189e..5bb4abadad20 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 @@ -176,14 +176,10 @@ describe('queued message cards', () => { 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' }) + draft('failed', 3, { paused: true, pausedReason: 'send_failed' }) ] expect(queuedMessagesQueuePause(project(unsendable), stopped)).toBeNull() // No published pause (an idle chat after a restart): plain cards, no header. @@ -192,13 +188,13 @@ describe('queued message cards', () => { 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', () => { + // A card held on its own (a failed send, or a newer host's hold) holds nothing behind it. + it('a card behind a card held on its own is not held by it', () => { const cards = projectQueuedMessageCards( [ draft('failed', 1, { paused: true, pausedReason: 'send_failed' }), draft('after-failed', 2), - draft('kept', 3, { paused: true, pausedReason: 'kept' }), + draft('newer-hold', 3, { paused: true }), draft('behind', 4) ], [], diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f08bd2908d4b..b7df897224a0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18317,10 +18317,8 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", - "pausedKept": "Not sent yet — press Send to send it.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", - "queuePausedCleared": "Queue paused after you cleared the conversation", "queuePaused": "Queue paused", "resume": "Resume", "sendConfirmTitle": "Send message?", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6ab2e3b60265..68350096d0be 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17948,10 +17948,8 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", - "pausedKept": "Aún no se ha enviado. Pulsa Enviar para enviarlo.", "paused": "En pausa", "queuePausedStopped": "Cola en pausa porque interrumpiste", - "queuePausedCleared": "Cola en pausa después de que borraste la conversación", "queuePaused": "Cola en pausa", "resume": "Reanudar", "sendConfirmTitle": "¿Enviar mensaje?", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2ea7b0eb11e9..da850203b050 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18165,10 +18165,8 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", - "pausedKept": "Pas encore envoyé. Appuyez sur Envoyer pour l'envoyer.", "paused": "En pause", "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", - "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 ?", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7daa71ae1e40..1fdf951c3fa8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18101,10 +18101,8 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", - "pausedKept": "まだ送信されていません。「送信」を押すと送信されます。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", "resume": "再開", "sendConfirmTitle": "メッセージを送信しますか?", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e8ed505c9f54..745146741c76 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18101,10 +18101,8 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", - "pausedKept": "아직 보내지 않았습니다. 보내기를 눌러 보내세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", "resume": "재개", "sendConfirmTitle": "메시지를 보낼까요?", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b77c300a974f..e2b541b75553 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18066,10 +18066,8 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", - "pausedKept": "尚未发送。点按发送即可发送。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", "resume": "继续", "sendConfirmTitle": "发送消息?", diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index a1df60b85f6f..bc18aa3d8f6b 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -7,21 +7,15 @@ import type { AgentJournalMessageItem } from './agent-session-journal-types' /** The draft could not be converted into a send; an explicit Send retries it. */ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const -/** A person's message the host accepted and never handed over before a restart or a close of the - * chat. It waits for the person's own Send; the cards behind it still send, as past a failed one. */ -export const QUEUED_MESSAGE_PAUSED_KEPT = 'kept' as const - -export type AgentSessionQueuedMessagePausedReason = - | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED - | typeof QUEUED_MESSAGE_PAUSED_KEPT - -/** The whole queue is paused and sends nothing on its own: 'stopped' — the user - * interrupted ("Queue paused because you interrupted") — 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' | 'cleared' } +export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED + +/** The whole queue is paused and sends nothing on its own: 'stopped' — the user interrupted + * ("Queue paused because you interrupted"). 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 /clear or a reopen also holds cards until the chat's next turn, but nothing + * runs then, so neither is published. A client treats an unknown reason as a plain pause, so a + * newer host can add one. */ +export type AgentSessionQueuePause = { reason: 'stopped' } /** What rides beside a frame's `queuedMessages`, published together with the list. */ export type AgentSessionQueuePublicationFields = { diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 08dbf9ed1f98..5076e6023d14 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: 'cleared' } }) + coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'stopped' } }) coalescer.push(batch(2)) coalescer.flush() - expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'cleared' } }) + expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'stopped' } }) coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index d7444e2c77e6..94bbd54d5652 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -23,8 +23,8 @@ import { import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A release that knows neither the Stop event nor the Resume marker: an unknown row kind would -// make it delete the journal from that row on, so both ride a tombstone it already reads. +// A release that knows none of the Stop event, the Resume marker and the reopen mark: an unknown +// row kind would make it delete the journal from that row on, so each rides a tombstone it reads. const BASELINE_REF = 'v1.4.218' const JOURNAL = 'src/main/native-chat/agent-session-journal' // A main build that shares this one's host database and schema version, so a downgrade to it opens @@ -72,7 +72,7 @@ type OldReplay = { } // Both downgrade probes load real old builds, including cold extraction and transforms. -test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { +test("an older build keeps every row around a Stop's event, a Resume and a reopen mark, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -89,6 +89,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) + await journal.appendQueueReopen(1) const afterMarks = journal.cursor() await append(1, 'after the Stop') const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) @@ -155,7 +156,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol }) expect(projected.ok).toBe(true) expect(projected.batch?.items).toEqual([]) - expect(projected.batch?.removedItemIds).toHaveLength(2) + expect(projected.batch?.removedItemIds).toHaveLength(3) const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) } finally { @@ -203,9 +204,7 @@ test("an older build opens this build's journal writable and appends to it; the await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) const wrote = { cursor: journal.cursor(), items: itemIds(journal) } - expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ - 'stopped' - ]) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) await journals.closeAll() const rowsBefore = storedRows(directory) @@ -232,9 +231,7 @@ test("an older build opens this build's journal writable and appends to it; the const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) - expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ - 'stopped' - ]) + expect(upgraded.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) diff --git a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts index 86eed2b8271e..fd1bf5fa5565 100644 --- a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -11,19 +11,17 @@ import { claudeProviderHandle } from '../../../src/shared/agent-session-provider import { USER_MESSAGE_SOURCE } from '../../../src/shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../src/shared/agent-session-queued-message-wire' import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { holdUnsentSends } from '../../../src/main/native-chat/agent-session-journal/journal-unsent-send-hold' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A kept send is an ordinary waiting card held by a new value in its existing `hold_reason` -// column, placed ahead of the queue: no new state or column. A build with the queue but without -// this change reads an unknown hold as a plain one: it must list the card first and never send it, -// even after a person's turn ends a restart's pause, and must still settle a send this build's quit -// left queued. One exception lives in that build's own code: its /clear carries every card over -// without its hold, so the replacement's next turn sends a carried kept card. The main commit this change branched from, which has the queue; move it to the -// newest release that has the queue and predates this change. A baseline holding this change tests -// no downgrade. +// A kept send is an ordinary waiting card placed ahead of the queue, and the reopen's mark is a +// tombstone carrier key: no new state, column or row kind. A build with the queue but without +// either holds a card another host process wrote under its own restart pause, so it must list the +// kept card first, never send it by itself, send it first once a turn there ends that pause, and +// still settle a send this build's quit left queued. The main commit the kept send branched from, +// which has the queue; move it to the newest release that has the queue and predates it. A +// baseline holding this change tests no downgrade. const BASELINE_REF = '5a56636f6679071d6ec68b851ef7932cd3222560' const JOURNAL = 'src/main/native-chat/agent-session-journal' const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { @@ -130,7 +128,7 @@ async function acceptPersonSend( }) } -test('an older build lists a kept card first and never sends it, even after a person’s turn', async () => { +test('an older build lists a kept card first, holds it, and sends it first once a turn there lifts its pause', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-kept-card-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -150,6 +148,7 @@ test('an older build lists a kept card first and never sends it, even after a pe hostInstance: 'host-b', hold: { cause: 'hostRestarted' } }) + await reopened.markQueueReopen(0) expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ 'kept', 'queued-card' @@ -166,25 +165,22 @@ test('an older build lists a kept card first and never sends it, even after a pe state, holdReason })) + expect(downgraded.repair).toEqual({ malformedRows: 0 }) expect(cards()).toEqual([ - { messageId: 'kept', state: 'waiting', holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, + { messageId: 'kept', state: 'waiting', holdReason: null }, { messageId: 'queued-card', state: 'waiting', holdReason: null } ]) expect(downgraded.queuedMessages.list()[0]!.position).toBeLessThan(1) expect(downgraded.submission('kept')).toMatchObject({ dispatchState: 'rejected' }) - // A person's turn there adopts every card into its process, which ends the restart's - // pause; the kept card's hold survives it, so its drain never picks it. That build skips a - // held card as it skips a failed send's, so the card behind it is the one it sends. + // Written by another process, so that build's restart pause holds both: nothing sends. + const pauses = () => downgraded.queuedMessages.pauses('host-c') + expect(pauses().map((pause) => pause.reason)).toEqual(['restarted']) + expect(nextSendable(pauses(), downgraded.queuedMessages.list())).toBeNull() + // A person's turn there adopts every card into its process, which ends that pause; the + // kept card, first in the queue, is the one it sends first. expect(await downgraded.queuedMessages.adopt('host-c')).toBe(true) - expect(cards()[0]).toMatchObject({ - messageId: 'kept', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(downgraded.queuedMessages.pauses('host-c')).toEqual([]) - expect( - nextSendable(downgraded.queuedMessages.pauses('host-c'), downgraded.queuedMessages.list()) - ?.messageId - ).toBe('queued-card') + expect(pauses()).toEqual([]) + expect(nextSendable(pauses(), downgraded.queuedMessages.list())?.messageId).toBe('kept') } finally { await older.closeAll() } From 18441aef3074789aca19cd5e45493f877d17eb05 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:58:45 -0700 Subject: [PATCH 27/31] fix(native-chat): mark the reopen in the one open path, and keep an idle chat with waiting cards open Review round 1: the startup restore opened chats past the per-host first-open mark, so their cards could send by themselves after a quit or crash; a card mid-hand-off at the open got no mark; a close that left the chat open re-marked after every new send. - Every open marks when a card waits, or is mid-hand-off with its send unanswered. - The idle sweep keeps a chat's handle while cards wait, so its eviction never reopens one and stays invisible; it drops it on the next sweep once they leave. - A person's close marks once; its delivery re-check marks only when it settled a send. - A failed mark holds from where the mark would have gone. - A Stop made after a reopen shows its row. - The rig's restart is a real quit and relaunch. --- .../MobileNativeChatQueuedMessages.tsx | 2 +- ...bile-structured-queued-message-controls.ts | 4 +- .../journal-queued-messages.ts | 13 ++ .../agent-session-journal/journal-store.ts | 18 +- .../queued-message-pause.test.ts | 26 +++ ...red-agent-session-conversation-lifetime.ts | 14 +- ...uctured-agent-session-conversation-open.ts | 12 +- .../structured-agent-session-delivery-loop.ts | 2 +- .../structured-agent-session-host-delivery.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 39 ++-- ...tructured-agent-session-idle-sweep.test.ts | 1 + .../structured-agent-session-idle-sweep.ts | 5 + ...gent-session-provider-child-record.test.ts | 12 +- ...session-queued-message-rig.test-fixture.ts | 9 +- ...ured-agent-session-queued-messages.test.ts | 2 +- ...ed-agent-session-queued-pause-lift.test.ts | 171 +----------------- .../structured-agent-session-queued-pause.ts | 59 +----- ...ctured-agent-session-queued-publication.ts | 17 +- ...ent-session-restore-without-import.test.ts | 11 +- ...red-agent-session-unsent-send-hold.test.ts | 29 ++- .../agent-session-queued-message-wire.ts | 9 +- 21 files changed, 172 insertions(+), 291 deletions(-) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 4b53ee81556d..c825bca9dca5 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -26,7 +26,7 @@ const RESUME_KEY = '\u0000resume' export type MobileNativeChatQueuedMessagesProps = { cards?: MobileQueuedMessageCard[] /** Steer for a waiting card, the paused queue's included; plain Send for a card held on its own - * (its send failed, or the host kept it unsent), or a returned one. */ + * (its send failed), or a returned one. */ onSend?: (messageId: string) => Promise onDelete?: (messageId: string) => Promise /** Copy the card's text into the composer, then delete the card. */ diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index f65ecf2d46e3..a786eeacbe09 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -1,8 +1,8 @@ // The queued-draft surface the structured session exposes: cards derived from // the published list and the Send-now / Delete / Edit actions. Shown whatever // the queue capability says: a host that does not queue sends still publishes a -// message it kept across a restart or a close, and only queueing a new send is -// gated. A host older than the queue publishes no list, so shows no cards. +// message it kept as a card across a restart or a close, and only queueing a new +// send is gated. A host older than the queue publishes no list, so shows no cards. // Nothing here is durable: the host owns the queue, and the published list is // the only truth a card action ever needs. 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 058921e6a8bb..17de1e5e9e7e 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 @@ -166,6 +166,19 @@ export class JournalQueuedMessages { return this.derivePauses(this.list()) } + /** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running + * marks it (`AgentSessionJournal.markQueueReopen`). */ + awaitReopenMark(): boolean { + const { submissions } = this.deps.state() + return this.list().some((row) => { + if (row.state === 'waiting') { + return true + } + const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined + return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') + }) + } + /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ userStopInForce(): JournalQueuePauseMarks['latestStop'] { const state = this.deps.state() diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 8525966ba11c..67675bf3c6a1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -85,7 +85,7 @@ export class AgentSessionJournal { private state: JournalReducerState private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } - private reopenUnmarked = false + private reopenUnmarked: AgentJournalCursor | null = null private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly rowWriter: JournalRowWriter @@ -161,12 +161,10 @@ export class AgentSessionJournal { ) } - /** Where the reopen's pause begins when this handle could not write its mark: the open itself. - * Null once marked, or when nothing needed one. Per handle, so the next open marks again. */ + /** Where the reopen's pause begins when this handle could not write its mark: where the mark + * would have gone. Null once a mark is written. Per handle, so the next open marks again. */ reopenFloor(): AgentJournalCursor | null { return this.reopenUnmarked - ? { epoch: this.openedThrough.epoch, sequence: this.openedThrough.sequence + 1 } - : null } async open(): Promise { @@ -324,13 +322,13 @@ export class AgentSessionJournal { return this.rowWriter.append(journalQueueReopenRowBuilder(() => this.state, fence)) } - /** Marks the reopen when cards wait; a failed write leaves the open itself as the pause's start - * (`reopenFloor`), and still throws. */ + /** Marks the reopen when a card waits or is mid-hand-off (it may come back to waiting); a failed + * write leaves where the mark would have gone as the pause's start (`reopenFloor`), and throws. */ async markQueueReopen(fence: number): Promise { - if (this.queuedMessages.list().some((row) => row.state === 'waiting')) { - this.reopenUnmarked = true + if (this.queuedMessages.awaitReopenMark()) { + this.reopenUnmarked = { epoch: this.state.epoch, sequence: this.state.lastSequence + 1 } await this.appendQueueReopen(fence) - this.reopenUnmarked = false + this.reopenUnmarked = null } } 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 89754a066451..15710f3cc90c 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 @@ -530,6 +530,32 @@ describe("a reopen's pause", () => { expect(reason(journal)).toBe('restarted') }) + // A hand-off in flight may come back to waiting after the open: the open marks for it too. + it('marks a reopen whose only card is mid-hand-off, and holds it once it comes back', async () => { + let journal = await open() + await queueDraft(journal, 'in-flight') + await journal.appendSubmission( + { + clientMessageId: 'drain-1', + payloadFingerprint: 'fp-in-flight', + body: message('in-flight'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'in-flight', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + journal = await reopen(journal) + await journal.resolveDispatch({ + clientMessageId: 'drain-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: 0 + }) + expect(held(journal)).toEqual([['in-flight', true]]) + }) + it('a reopen with no waiting card writes nothing', async () => { let journal = await open() await turn(journal, 'earlier') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index fec48d66d396..6f222f2f0f57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -25,7 +25,6 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { forgetStructuredQueueOpen } from './structured-agent-session-queued-pause' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -160,14 +159,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { - // Settled and marked before the stop, so no start delivers it. - await holdClosedStructuredAgentSessionSends( - { ...deps(), sessions }, - sessionId, - session.journal - ) - } else { - forgetStructuredQueueOpen(sessions, sessionId) + // Settled and marked before the stop, so no start delivers it, and nothing it closed with + // sends by itself, even if the handle stays open. + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 194173797112..3559d1740c9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -25,7 +25,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { - markStructuredQueueFirstOpen, + markStructuredQueueReopen, structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { @@ -77,13 +77,6 @@ export async function openStructuredAgentSessionConversation( return null } const opened = await openStructuredAgentSessionConversationJournal(context.deps, record, options) - // After the open made its leftovers cards, so the mark follows every card it found. - await markStructuredQueueFirstOpen( - { sessions: context.sessions, logger: context.deps.logger }, - sessionId, - opened.session.journal, - record.lease.runtimeFence - ) await context.adoptOpened(sessionId, opened) return opened.session } @@ -132,6 +125,9 @@ export async function openStructuredAgentSessionConversationJournal( error }) } + // After the leftovers became cards, so the mark follows every card this open found. Every open + // comes after the chat stopped running: the idle sweep never closes one with cards waiting. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index afadc950bce6..d90bff02c0c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -174,7 +174,7 @@ export class StructuredAgentSessionDeliveryLoop { }) if (settled) { // The open's settle failed, so it marked nothing for the cards this made. - await markStructuredQueueReopen(this.deps, sessionId, session.journal, fence) + await markStructuredQueueReopen(sessionId, session.journal, fence, this.deps.logger) } if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 3b2b2926065e..f1e61d1c8545 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -68,12 +68,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { holdClosed: async (sessionId, which) => { const session = sessions.get(sessionId) return session - ? holdClosedStructuredAgentSessionSends( - { ...deps, sessions }, - sessionId, - session.journal, + ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { + mark: 'settled', which - ) + }) : true }, failureTextContext: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 748706e95133..31c61a8d7c29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -61,40 +61,39 @@ export type StructuredAgentSessionLifetimeContext = { type ConversationCloseDeps = Pick & { store: Pick - /** The host's open conversations (`markStructuredQueueReopen`). */ - sessions: ReadonlyMap } /** What is still queued when the chat closes will not be handed over: a person's message is kept - * as a card that waits for the chat's next turn, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the - * next open settles what it left. `which` narrows it to the messages a close that did not complete + * as a card that waits for the chat's next turn, the rest rejected (`journal-unsent-send-hold.ts`). + * A quit is not a close: the next open settles what it left. The chat stops running, so the + * reopen mark follows (`markStructuredQueueReopen`): on every `close`, or only once it settled a + * send, for a later re-check of the same close, which must never mark past a new send. `which` narrows it to the messages a close that did not complete * closed. Best effort, so a close never waits on it: resolves false when it failed, reported and * never thrown. */ export async function holdClosedStructuredAgentSessionSends( deps: ConversationCloseDeps, sessionId: string, journal: StructuredAgentSessionHostSession['journal'], - which?: (submission: AgentJournalSubmission) => boolean + close: { mark: 'always' | 'settled'; which?: (submission: AgentJournalSubmission) => boolean } ): Promise { const fence = structuredAgentSessionConversationFence(deps.store, sessionId) - const held = await holdUnsentSends(journal, { + const { which } = close + const settled = await holdUnsentSends(journal, { fence, hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'chatClosed', ...(which ? { which } : {}) } - }).then( - () => true, - (error: unknown) => { - deps.logger.warn('settling queued messages of a closed chat failed', { - scope: 'queued-abandon', - sessionId, - error - }) - return false - } - ) - // The chat stopped running: nothing it closed with sends by itself, even if it stays open. - await markStructuredQueueReopen(deps, sessionId, journal, fence) - return held + }).catch((error: unknown) => { + deps.logger.warn('settling queued messages of a closed chat failed', { + scope: 'queued-abandon', + sessionId, + error + }) + return null + }) + if (close.mark === 'always' || settled) { + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) + } + return settled !== null } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 9cb6dbab4ef2..5163ede76c80 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -278,6 +278,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { const journal = { submissions: () => [], pendingSubmissions: () => [], + queuedMessages: { list: () => [] }, snapshot: () => ({ items: [ { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 1b67f259ed95..a91b30db3756 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -126,6 +126,11 @@ export class StructuredAgentSessionIdleSweep { } await this.deps.stopAgent(sessionId) } + // A chat with cards waiting keeps its handle: a reopen marks them to wait for a turn + // (`markStructuredQueueReopen`), which an eviction the person never saw must not do. + if (session.journal.queuedMessages.list().some((row) => row.state === 'waiting')) { + return + } await this.deps.closeConversation(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 2551cd33237c..81e9d646b6d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -29,6 +29,7 @@ import { ensureStructuredAgentSessionAgent } from './structured-agent-session-ag import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { HOST_TEST_LOCATION, HOST_TEST_NOW as NOW, @@ -782,7 +783,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) - it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { + it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { const start = deferred() adapterExtras = { awaitStarted: vi.fn(() => start.promise), @@ -802,6 +803,15 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) expect(acquire).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() + // The close stopped the chat running: the card waits for its next turn, by a mark the + // re-check wrote once, when it kept the card. + const journal = conversation()!.journal + expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) + const marks = vi.spyOn(journal, 'appendQueueReopen') + await accept('second', { person: true }) + await settleLoop() + // A later send's re-check settles nothing, so it marks nothing past that send. + expect(marks).not.toHaveBeenCalled() }) it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index e41246fec751..8fd1155442d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -195,13 +195,10 @@ export async function createQueuedMessageTestRig( }) } - /** A host-process restart, as the queue sees it: the conversation closes, and - * opens afresh under a new instance id while its rows survive. The close is an eviction, whose - * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that - * pause across a restart uses `crashRestartHostProcess`. */ + /** A host-process restart: the app quits (its own teardown runs) and a new host opens the same + * state. A quit writes no close's Stop event, so a person's Stop pause survives it. */ async function restartHostProcess(): Promise { - await host.close(SESSION, 'evict') - rotateStructuredAgentSessionHostInstanceForTests() + await quitRestartHostProcess() } /** A host process that dies with no close: a new host opens the same state directory. */ 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 5308682e8183..b5a4e76c866a 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 @@ -297,7 +297,7 @@ describe('held drafts', () => { await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toBeNull() - // The user's send starting its turn lifts it, and adopts the row into this instance. + // The user's send starting its turn lifts it. const next = send('user starts a new turn') await next.result expect(await rig.handoff(draftId)).toBeUndefined() 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 cf1746eaaf0a..b2a67db55678 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 @@ -8,8 +8,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa import { HOST_TEST_SESSION, HOST_TEST_THREAD as THREAD, - hostTestMessage, - hostTestOperationId + hostTestMessage } from './structured-agent-session-host-test-data' import { QUEUED_RIG_CALLER, @@ -18,11 +17,7 @@ import { type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - structuredAgentSessionHostInstance, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -373,16 +368,17 @@ describe("a restart's pause", () => { await next.result await rig.settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - // Reopened, that turn is "before this open", yet the pause it ended stays ended: - // the lift adopted the rows into this process. + // Closed again with a card still waiting, it waits again for the next turn, unshown. await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) + expect(derivedPauses()).toEqual(['restarted']) }) }) describe('a card handed off after a restart', () => { - it('belongs to the process that sent it: withdrawn back to waiting, it raises no restart pause', async () => { + // Returned, not waiting, when Orca restarted: the reopen holds nothing; only the Stop does. + it('sent again and withdrawn by a Stop, it waits under that Stop alone, shown; Resume lifts it', async () => { const working = await rig.workingSend() const draftId = await queuedDraft('refused, then re-sent after a restart') await rig.settleAccepted(working, 'a') @@ -392,7 +388,7 @@ describe('a card handed off after a restart', () => { expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) ) await rig.restartHostProcess() - // Sent again in this process, then withdrawn by a Stop before the agent had it. + // Sent again after the restart, then withdrawn by a Stop before the agent had it. let release: () => void = () => undefined rig.awaitStarted.mockImplementationOnce( () => new Promise((resolve) => (release = () => resolve(undefined))) @@ -403,156 +399,9 @@ describe('a card handed off after a restart', () => { await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) ) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - expect(journal.queuedMessages.get(draftId)?.hostInstance).toBe( - structuredAgentSessionHostInstance() - ) - // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.appendQueueResume( - structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) - ) - expect(structuredQueuePauses(journal)).toEqual([]) - }) -}) - -describe('Resume', () => { - it('lifts the pause and the queue drains, oldest first; a second Resume is a no-op', async () => { - const working = await rig.workingSend() - const first = await queuedDraft('first') - const second = await queuedDraft('second') - await rig.stop() - await rig.settleAccepted(working, 'stopped') - await expectPaused(first, second) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - expect(await rig.queuePause()).toBeNull() - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - expect(await rig.handoff(second)).toBeUndefined() - // Nothing is paused now: another Resume changes nothing. - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) - }) - - it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => { - const working = await rig.workingSend() - const draftId = await queuedDraft('paused twice') - await rig.stop() - const operationId = hostTestOperationId() - expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } }) - await rig.stop() - expect(await rig.resume(operationId)).toMatchObject({ - ok: true, - replayed: true, - value: { resumed: false } - }) - await rig.settleAccepted(working, 'stopped') - await expectPaused(draftId) - }) - - 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(derivedPauses()).toEqual(['stopped']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) - }) - - it('is a no-op on a queue that is not paused', async () => { - await rig.workingSend() - await queuedDraft('waiting behind the turn') - expect(await rig.queuePause()).toBeNull() - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) - }) -}) - -describe('a failed Stop', () => { - // Withdrawing is bookkeeping: its failure is reported, and the Stop still interrupts and pauses. - it('still takes effect when its withdrawal fails, and pauses the queue', async () => { - await rig.workingSend() - const draftId = await queuedDraft('queued before the stop') - const reject = vi - .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') - .mockRejectedValueOnce(new Error('disk full')) - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(warned).toHaveBeenCalledWith( - "[agent-session] stop-queued-bookkeeping: Stop's withdrawal failed", - expect.objectContaining({ step: 'withdrawal', error: new Error('disk full') }) - ) - } finally { - reject.mockRestore() - warned.mockRestore() - } - await expectPaused(draftId) - }) - - it('keeps its pause when it fails after the interrupt reached the agent', async () => { - await rig.workingSend() - const draftId = await queuedDraft('paused by stop') - const append = AgentSessionJournal.prototype.appendItem - const failing = vi - .spyOn(AgentSessionJournal.prototype, 'appendItem') - .mockImplementation(async function (this: AgentSessionJournal, ...args) { - // The status note written after the provider was asked to stop. - if (args[1].kind === 'status') { - throw new Error('disk full') - } - return append.apply(this, args) - }) - try { - await expect(rig.stop()).rejects.toThrow('disk full') - } finally { - failing.mockRestore() - } - await expectPaused(draftId) - }) - - // The drain shares the Stop's lane, so a failed Stop must hold it until its pause lands. - it('keeps its pause when it fails while its writes wait behind owed work', async () => { - const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-w', ordinal: 900 } - const turnRow = (state: 'running' | 'completed') => { - rig - .providerEvents() - .appendItem( - turn, - { kind: 'turn', turnId: 'turn-w', state, startedAt: 1 }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const working = await rig.workingSend() - turnRow('running') - await rig.settleAccepted(working, 'w') - const draftId = await queuedDraft('paused by stop') - // The interrupt ends the turn, so the drain runs as soon as the lane frees. - vi.mocked(rig.host.deps.adapter.cancelTurn).mockImplementationOnce(async () => { - turnRow('completed') - return { cancelled: true } - }) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('no open journal') - } - const append = journal.appendItem.bind(journal) - vi.spyOn(journal, 'appendItem').mockImplementation((identity, body, options) => - body.kind === 'status' - ? Promise.reject(new Error('disk full')) - : append(identity, body, options) - ) - // Owed from the Stop's first write, after the open paid any import: its writes wait behind it. - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - const stopping = rig.stop() - await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce()) - owed.resolve() - await expect(stopping).rejects.toThrow('disk full') - expect(journal.activeTurnId()).toBeNull() - await expectPaused(draftId) + expect(derivedPauses()).toEqual([]) }) }) 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 85433b616301..ff1baad57541 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 @@ -11,28 +11,12 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo * `runtimeId` (`orca-runtime-runtime-id.ts`), stamped on the cards this process writes or hands * off. No pause reads it: an older build holds every card another instance wrote. */ let hostInstance = randomUUID() -/** Per host, keyed by its open-conversations map: the conversations it has opened and marked (or - * found nothing to mark). A later open is the idle sweep's eviction coming back, which the person - * never saw, so it marks nothing. A failed mark leaves its conversation out, so the next open - * tries again. In memory, so a new host process marks again. */ -const openedHere = new WeakMap>() - -type HostConversations = ReadonlyMap - -function openedBy(sessions: HostConversations): Set { - let opened = openedHere.get(sessions) - if (!opened) { - opened = new Set() - openedHere.set(sessions, opened) - } - return opened -} export function structuredAgentSessionHostInstance(): string { return hostInstance } -/** Simulates a host-process restart. Tests only. */ +/** A new stamp, as a new host process mints. Tests only; no pause reads it. */ export function rotateStructuredAgentSessionHostInstanceForTests(): string { hostInstance = randomUUID() return hostInstance @@ -46,46 +30,24 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ } /** The mark of a chat that stopped running with cards waiting — Orca quit or crashed, or the chat - * was closed — so they wait for its next turn (`queued-message-pause.ts`). Bookkeeping, so a - * failure is reported and never thrown; the pause then starts at the open itself, holding no - * less. Resolves whether it marked, or found nothing to mark. */ + * was closed — so they wait for its next turn (`queued-message-pause.ts`). Every open marks, and + * so does a person's close: the idle sweep never closes a chat with cards waiting, so its + * eviction never reopens one. Bookkeeping, so a failure is reported and never thrown; the pause + * then starts where the mark would have gone, holding no less. */ export async function markStructuredQueueReopen( - host: { sessions: HostConversations; logger: StructuredAgentSessionLogger }, sessionId: string, journal: Pick, - fence: number -): Promise { - const { logger } = host + fence: number, + logger: StructuredAgentSessionLogger +): Promise { try { await journal.markQueueReopen(fence) - return true } catch (error) { - // Unmarked, so this host's next open of the chat marks it. - openedBy(host.sessions).delete(sessionId) logger.warn('marking a reopened queue failed', { scope: 'queue-reopen-mark', sessionId, error }) - return false - } -} - -/** The open's mark, on this host's first open of the chat only (or the first since a close of it - * that had no conversation open): a restart or a crash ended it running elsewhere, while an idle - * eviction here changes nothing the person sees. */ -export async function markStructuredQueueFirstOpen( - host: { sessions: HostConversations; logger: StructuredAgentSessionLogger }, - sessionId: string, - journal: Pick, - fence: number -): Promise { - const opened = openedBy(host.sessions) - if ( - !opened.has(sessionId) && - (await markStructuredQueueReopen(host, sessionId, journal, fence)) - ) { - opened.add(sessionId) } } @@ -100,8 +62,3 @@ export async function resumeStructuredQueue( await journal.appendQueueResume(fence) return true } - -/** A close of a chat this host has no conversation open for: its next open marks it. */ -export function forgetStructuredQueueOpen(sessions: HostConversations, sessionId: string): void { - openedBy(sessions).delete(sessionId) -} 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 2fafb1e1f627..d48d9bf27c39 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 @@ -115,11 +115,20 @@ export function readQueuePublication( // Read per emit: the pause also turns on submissions (a turn starting). Only a person's Stop is // shown, and 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 /clear, a restart or a close nothing - // is shown, a Stop's included: nothing runs in the chat, its next turn lifts every pause, and the - // cards read as plain waiting cards until then. An idle eviction marks nothing, so it hides - // nothing. + // is shown, a Stop's from before it included: nothing runs in the chat, its next turn lifts every + // pause, and the cards read as plain waiting cards until then. A Stop the person makes after the + // reopen shows. const pauses = structuredQueuePauses(journal) - const silent = pauses.some((pause) => pause.reason !== 'stopped') + const stop = pauses.find((pause) => pause.reason === 'stopped')?.since + const silent = pauses.some( + (pause) => + pause.reason === 'cleared' || + (pause.reason === 'restarted' && + (!stop || + !pause.since || + stop.epoch !== pause.since.epoch || + stop.sequence < pause.since.sequence)) + ) const resumable = silent ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) // The submissions are read only while a pause would show, never while the queue runs freely. const pause = diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index e81ec745d0e7..4b969037787f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -365,7 +365,8 @@ describe('startup restore of chats still in their per-chat files', () => { ) // A send the last run never handed over is kept as a card in the same database the copy wrote, - // after the copy: the card and the rejected send both land behind the chat's own rows. + // after the copy: the card and the rejected send both land behind the chat's own rows, then the + // reopen's mark, so the card waits for the chat's next turn. it('keeps a send the last run never handed over as a card, after the copy', async () => { const rows = await seedLegacyChat('chat-kept', 0, 'never handed over') @@ -375,12 +376,16 @@ describe('startup restore of chats still in their per-chat files', () => { await journal.whenImported() const copied = readTestJournalRows(hostDb(), 'chat-kept', rows[0]!.epoch) expect(copied.slice(0, rows.length)).toEqual(rows) - expect(copied).toHaveLength(rows.length + 1) - expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({ + expect(copied).toHaveLength(rows.length + 2) + expect(JSON.parse(copied.at(-2)!.rowJson)).toMatchObject({ kind: 'dispatch', clientMessageId: 'client-chat-kept', state: 'rejected' }) + expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({ + kind: 'tombstone', + queueReopen: true + }) const cards = hostDb() .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') .all('chat-kept') 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 f9abc82a10aa..329b776dbc0a 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 @@ -16,7 +16,10 @@ import { openAgentSessionJournal } from '../agent-session-journal/journal-store- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + structuredAgentSessionHostInstance, + structuredQueuePauses +} from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -694,11 +697,31 @@ describe("the reopen's mark", () => { await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() expect(rig.closeSession).toHaveBeenCalledTimes(1) - // Its handle was only a cache, so it closed; the read below reopens it. - expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + // A chat with cards waiting keeps its handle, so no reopen can mark them. + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.handoff(held.id)).toBeUndefined() expect(marks).not.toHaveBeenCalled() + // Kept only while a card waits: once the last one leaves, the next sweep drops it as usual. + expect(await rig.deleteQueued(held.id)).toMatchObject({ ok: true, value: { deleted: true } }) + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + }) + + // The startup restore opens a visible chat through the same open as any reader. + it('a chat the startup restore reopens after a crash holds its cards', async () => { + const working = await rig.workingSend() + const queued = rig.send('queued behind work', 'queue-if-active') + await queued.result + void working + const sent = rig.dispatch.mock.calls.length + rig.crashRestartHostProcess() + await rig.host.restoreReadableSessions([SESSION]) + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(sent) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) }) it('an idle chat with no waiting card is closed and reopened without writing anything', async () => { diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index bc18aa3d8f6b..d10d263aa024 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -38,12 +38,11 @@ export type AgentSessionQueuedMessage = { position: number body: AgentJournalMessageItem state: 'waiting' | 'returned' - /** This one card is held, whatever the queue's pause: its conversion failed, or the host kept it. */ + /** This one card is held, whatever the queue's pause: its conversion failed. */ paused?: true - /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't - * send") or 'kept' (accepted, never sent); only an explicit Send releases either. A client - * must treat an unknown marker as a plain hold, so a newer host can add one. The queue-level - * pause is `queuePause`, published beside the list. */ + /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't send"); only an + * explicit Send releases it. A client must treat an unknown marker as a plain hold, so a newer + * host can add one. The queue-level pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host From e47faa905005940da3a1860f280eadb2f6cdd609 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:18:32 -0700 Subject: [PATCH 28/31] fix(native-chat): review round 2: restore the dropped Resume and failed-Stop tests; a late mark starts where the chat stopped - Restores eight tests the previous commit dropped by mistake. - A mark the delivery loop or a close's re-check writes after a later send starts where the chat stopped, so that send still lifts it; a later mark never narrows an earlier, wider one. - Only the idle sweep's own close keeps a chat with a card waiting (or mid-hand-off) open, and it still releases an ended child's lease first; a person's close drops it as before. --- .../journal-row-schema.ts | 3 + .../journal-stop-and-resume-rows.ts | 11 +- .../agent-session-journal/journal-store.ts | 16 +- .../journal-unsent-send-hold.ts | 10 +- .../queued-message-pause.test.ts | 16 ++ .../queued-message-pause.ts | 5 +- ...red-agent-session-conversation-lifetime.ts | 7 +- .../structured-agent-session-delivery-loop.ts | 13 +- .../structured-agent-session-host-lifetime.ts | 41 +++-- ...tructured-agent-session-idle-sweep.test.ts | 1 - .../structured-agent-session-idle-sweep.ts | 5 - ...ed-agent-session-queued-pause-lift.test.ts | 144 +++++++++++++++++- .../structured-agent-session-queued-pause.ts | 10 +- 13 files changed, 231 insertions(+), 51 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 54fb4097dbb9..1d226761ae25 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -98,6 +98,9 @@ export type JournalTombstoneRow = JournalRowBase & { /** Present: not a removal but a reopen that found waiting cards (`queued-message-pause.ts`), on * an id no item ever takes. */ queueReopen?: true + /** On a reopen mark written after the chat stopped: where it stopped, so a send accepted since + * lifts it. Absent: the mark's own row. */ + queueReopenSince?: number } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index c70f1bbdaec3..c4ddb98bc398 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -45,12 +45,15 @@ export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow } } -export function buildJournalQueueReopenRow(input: RowPlace): JournalTombstoneRow { +export function buildJournalQueueReopenRow( + input: RowPlace & { since?: number } +): JournalTombstoneRow { return { kind: 'tombstone', itemId: JOURNAL_QUEUE_REOPEN_ITEM_ID, revision: 1, queueReopen: true, + ...(input.since !== undefined ? { queueReopenSince: input.since } : {}), ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) } } @@ -74,7 +77,9 @@ export function journalQueueResumeRowBuilder( export function journalQueueReopenRowBuilder( state: () => JournalReducerState, - fence: number + fence: number, + since?: number ): (seq: number, ts: number) => JournalTombstoneRow { - return (seq, ts) => buildJournalQueueReopenRow({ state: state(), seq, fence, ts }) + return (seq, ts) => + buildJournalQueueReopenRow({ state: state(), seq, fence, ts, ...(since ? { since } : {}) }) } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 67675bf3c6a1..20be44a8d21a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -318,16 +318,18 @@ export class AgentSessionJournal { } /** This open found waiting cards an earlier handle wrote (`queued-message-pause.ts`). */ - appendQueueReopen(fence: number): Promise { - return this.rowWriter.append(journalQueueReopenRowBuilder(() => this.state, fence)) + appendQueueReopen(fence: number, since?: number): Promise { + return this.rowWriter.append(journalQueueReopenRowBuilder(() => this.state, fence, since)) } - /** Marks the reopen when a card waits or is mid-hand-off (it may come back to waiting); a failed - * write leaves where the mark would have gone as the pause's start (`reopenFloor`), and throws. */ - async markQueueReopen(fence: number): Promise { + /** Marks the reopen when a card waits or is mid-hand-off (it may come back to waiting), from + * `since` when the chat stopped before now; a failed write leaves where the mark would have gone + * as the pause's start (`reopenFloor`), and throws. */ + async markQueueReopen(fence: number, since?: number): Promise { if (this.queuedMessages.awaitReopenMark()) { - this.reopenUnmarked = { epoch: this.state.epoch, sequence: this.state.lastSequence + 1 } - await this.appendQueueReopen(fence) + const sequence = since ?? this.state.lastSequence + 1 + this.reopenUnmarked = { epoch: this.state.epoch, sequence } + await this.appendQueueReopen(fence, since) this.reopenUnmarked = null } } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 3c3de5bf980a..fd72e54fc96d 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -61,13 +61,13 @@ export function unsentSendKeptAsCard( * both. This batch's cards, and the card of a Send the person asked for, go to the head of the * queue in the order they were accepted, behind the cards an earlier settlement kept; the queue's * own hand-off returns its card where it stood. Throws once every row was - * tried when one of them could not be written at all: that row stays queued. Resolves whether it - * settled any send. + * tried when one of them could not be written at all: that row stays queued. Resolves where the + * newest send it settled was accepted, or null when it settled none. */ export async function holdUnsentSends( journal: AgentSessionJournal, input: { fence: number; hostInstance: string; hold: UnsentSendHold } -): Promise { +): Promise { const { hold } = input const unsent = journal .submissions() @@ -80,7 +80,7 @@ export async function holdUnsentSends( ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { - return false + return null } const { epoch } = journal.cursor() const kept = unsent.map((submission) => ({ @@ -166,7 +166,7 @@ export async function holdUnsentSends( if (failures.length > 0) { throw new AggregateError(failures, 'settling unsent sends failed') } - return true + return unsent.at(-1)?.acceptedSequence ?? 0 } /** Where each card of the batch goes: right before every other card, the cards an earlier 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 15710f3cc90c..8346d407075a 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 @@ -556,6 +556,22 @@ describe("a reopen's pause", () => { expect(held(journal)).toEqual([['in-flight', true]]) }) + // Written after a later send was accepted, the mark starts where the chat stopped, so that + // send still lifts it; it never narrows a wider mark before it. + it('a mark written late starts where the chat stopped, and narrows no earlier mark', async () => { + const journal = await open() + await queueDraft(journal, 'kept') + const stopped = journal.cursor().sequence + 1 + await turn(journal, 'later') + await journal.markQueueReopen(0, stopped) + expect(reason(journal)).toBeNull() + + const reopened = await reopen(journal) + expect(reason(reopened)).toBe('restarted') + await reopened.markQueueReopen(0, stopped) + expect(reason(reopened)).toBe('restarted') + }) + it('a reopen with no waiting card writes nothing', async () => { let journal = await open() await turn(journal, 'earlier') 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 34fc06971ffa..10bbd6fac678 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 @@ -76,7 +76,10 @@ export function foldJournalQueuePauseMark( } else if (row.queueResume === true) { marks.resumedSequence = row.seq } else if (row.queueReopen === true) { - marks.reopenedSequence = row.seq + // Never earlier than a mark before it: a late mark of one send narrows no wider one. + const since = row.queueReopenSince + const start = typeof since === 'number' && since > 0 && since <= row.seq ? since : row.seq + marks.reopenedSequence = Math.max(marks.reopenedSequence, start) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 6f222f2f0f57..f7855b51049c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -57,7 +57,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) - const closeConversation = async (sessionId: string): Promise => { + const closeConversation = async (sessionId: string, atRest = false): Promise => { // The handle carries the proof that releases a lease its child's wind-down could not. if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false @@ -71,7 +71,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) } }, - sessionId + sessionId, + atRest ) } @@ -95,7 +96,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' }), - closeConversation, + closeConversation: (sessionId) => closeConversation(sessionId, true), logger: deps().logger, ...deps().idleSweep }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index d90bff02c0c1..32b5ce1900eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -166,15 +166,18 @@ export class StructuredAgentSessionDeliveryLoop { // The open already did, unless its write failed: a row an earlier handle wrote is never handed // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then // fails, which rejects every queued send, this process's own too. + const { journal } = session const fence = this.deps.conversationFence(sessionId) - const settled = await holdUnsentSends(session.journal, { + const hostInstance = structuredAgentSessionHostInstance() + const settled = await holdUnsentSends(journal, { fence, - hostInstance: structuredAgentSessionHostInstance(), + hostInstance, hold: { cause: 'hostRestarted' } }) - if (settled) { - // The open's settle failed, so it marked nothing for the cards this made. - await markStructuredQueueReopen(sessionId, session.journal, fence, this.deps.logger) + if (settled !== null) { + // The open's settle failed, so it marked nothing for the cards this made; a send that woke + // this loop came after them, so the mark starts where they did. + await markStructuredQueueReopen(sessionId, journal, fence, this.deps.logger, settled + 1) } if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 31c61a8d7c29..3e9c1460f4b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -82,18 +82,24 @@ export async function holdClosedStructuredAgentSessionSends( fence, hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'chatClosed', ...(which ? { which } : {}) } - }).catch((error: unknown) => { - deps.logger.warn('settling queued messages of a closed chat failed', { - scope: 'queued-abandon', - sessionId, - error - }) - return null - }) - if (close.mark === 'always' || settled) { + }).then( + (newest) => ({ ok: true, newest }), + (error: unknown) => { + deps.logger.warn('settling queued messages of a closed chat failed', { + scope: 'queued-abandon', + sessionId, + error + }) + return { ok: false, newest: null } + } + ) + if (close.mark === 'always') { await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) + } else if (settled.newest !== null) { + // A send that woke this re-check came after the ones it settled: the mark starts at them. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger, settled.newest + 1) } - return settled !== null + return settled.ok } /** @@ -166,14 +172,18 @@ export type StructuredAgentSessionCloseCause = Extract< > /** Whether the conversation's handle is only a cache now: no child, and nothing queued or waiting - * on the provider. */ + * on the provider. `atRest`: the idle sweep's own close, which also keeps a chat with a card + * waiting, since a reopen marks it to wait for a turn (`markStructuredQueueReopen`), and an + * eviction the person never saw must not; a person's close has marked them already. */ export function structuredAgentSessionConversationClosable( - session: StructuredAgentSessionHostSession + session: StructuredAgentSessionHostSession, + atRest = false ): boolean { return ( session.child === null && !session.journal.submissions().some(isQueuedAgentJournalSubmission) && - session.journal.pendingSubmissions().length === 0 + session.journal.pendingSubmissions().length === 0 && + !(atRest && session.journal.queuedMessages.awaitReopenMark()) ) } @@ -188,10 +198,11 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( /** The status row outlives the handle; see `StructuredAgentSessionClientDelivery`. */ closeStatus: (sessionId: string) => void }, - sessionId: string + sessionId: string, + atRest = false ): Promise { const session = context.sessions.get(sessionId) - if (!session || !structuredAgentSessionConversationClosable(session)) { + if (!session || !structuredAgentSessionConversationClosable(session, atRest)) { return false } context.sessions.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 5163ede76c80..9cb6dbab4ef2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -278,7 +278,6 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { const journal = { submissions: () => [], pendingSubmissions: () => [], - queuedMessages: { list: () => [] }, snapshot: () => ({ items: [ { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index a91b30db3756..1b67f259ed95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -126,11 +126,6 @@ export class StructuredAgentSessionIdleSweep { } await this.deps.stopAgent(sessionId) } - // A chat with cards waiting keeps its handle: a reopen marks them to wait for a turn - // (`markStructuredQueueReopen`), which an eviction the person never saw must not do. - if (session.journal.queuedMessages.list().some((row) => row.state === 'waiting')) { - return - } await this.deps.closeConversation(sessionId) } 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 b2a67db55678..732933106f22 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 @@ -8,7 +8,8 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa import { HOST_TEST_SESSION, HOST_TEST_THREAD as THREAD, - hostTestMessage + hostTestMessage, + hostTestOperationId } from './structured-agent-session-host-test-data' import { QUEUED_RIG_CALLER, @@ -356,7 +357,7 @@ describe('a pause only over cards Resume could send', () => { }) describe("a restart's pause", () => { - it('once a turn ends it, stays ended when the conversation reopens', async () => { + it('a turn ends it; closing again with a card still waiting holds that card again', async () => { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') @@ -405,3 +406,142 @@ describe('a card handed off after a restart', () => { expect(derivedPauses()).toEqual([]) }) }) + +describe('Resume', () => { + it('lifts the pause and the queue drains, oldest first; a second Resume is a no-op', async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await expectPaused(first, second) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + // Nothing is paused now: another Resume changes nothing. + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) + + it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused twice') + await rig.stop() + const operationId = hostTestOperationId() + expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } }) + await rig.stop() + expect(await rig.resume(operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + await rig.settleAccepted(working, 'stopped') + await expectPaused(draftId) + }) + + 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()) + }) + + it('is a no-op on a queue that is not paused', async () => { + await rig.workingSend() + await queuedDraft('waiting behind the turn') + expect(await rig.queuePause()).toBeNull() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) +}) + +describe('a failed Stop', () => { + // Withdrawing is bookkeeping: its failure is reported, and the Stop still interrupts and pauses. + it('still takes effect when its withdrawal fails, and pauses the queue', async () => { + await rig.workingSend() + const draftId = await queuedDraft('queued before the stop') + const reject = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockRejectedValueOnce(new Error('disk full')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(warned).toHaveBeenCalledWith( + "[agent-session] stop-queued-bookkeeping: Stop's withdrawal failed", + expect.objectContaining({ step: 'withdrawal', error: new Error('disk full') }) + ) + } finally { + reject.mockRestore() + warned.mockRestore() + } + await expectPaused(draftId) + }) + + it('keeps its pause when it fails after the interrupt reached the agent', async () => { + await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + const append = AgentSessionJournal.prototype.appendItem + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'appendItem') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + // The status note written after the provider was asked to stop. + if (args[1].kind === 'status') { + throw new Error('disk full') + } + return append.apply(this, args) + }) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + failing.mockRestore() + } + await expectPaused(draftId) + }) + + // The drain shares the Stop's lane, so a failed Stop must hold it until its pause lands. + it('keeps its pause when it fails while its writes wait behind owed work', async () => { + const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-w', ordinal: 900 } + const turnRow = (state: 'running' | 'completed') => { + rig + .providerEvents() + .appendItem( + turn, + { kind: 'turn', turnId: 'turn-w', state, startedAt: 1 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + const working = await rig.workingSend() + turnRow('running') + await rig.settleAccepted(working, 'w') + const draftId = await queuedDraft('paused by stop') + // The interrupt ends the turn, so the drain runs as soon as the lane frees. + vi.mocked(rig.host.deps.adapter.cancelTurn).mockImplementationOnce(async () => { + turnRow('completed') + return { cancelled: true } + }) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('no open journal') + } + const append = journal.appendItem.bind(journal) + vi.spyOn(journal, 'appendItem').mockImplementation((identity, body, options) => + body.kind === 'status' + ? Promise.reject(new Error('disk full')) + : append(identity, body, options) + ) + // Owed from the Stop's first write, after the open paid any import: its writes wait behind it. + const owed = Promise.withResolvers() + const withdraw = journal.rejectQueuedSubmissions.bind(journal) + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { + journal['queue'].owe(() => owed.promise) + return withdraw(...args) + }) + const stopping = rig.stop() + await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce()) + owed.resolve() + await expect(stopping).rejects.toThrow('disk full') + expect(journal.activeTurnId()).toBeNull() + await expectPaused(draftId) + }) +}) 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 ff1baad57541..e59a56a7c795 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 @@ -32,16 +32,18 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ /** The mark of a chat that stopped running with cards waiting — Orca quit or crashed, or the chat * was closed — so they wait for its next turn (`queued-message-pause.ts`). Every open marks, and * so does a person's close: the idle sweep never closes a chat with cards waiting, so its - * eviction never reopens one. Bookkeeping, so a failure is reported and never thrown; the pause - * then starts where the mark would have gone, holding no less. */ + * eviction never reopens one. `since`: where the chat stopped, for a mark written after a send + * that came later, which must still lift it. Bookkeeping, so a failure is reported and never + * thrown; the pause then starts where the mark would have gone, holding no less. */ export async function markStructuredQueueReopen( sessionId: string, journal: Pick, fence: number, - logger: StructuredAgentSessionLogger + logger: StructuredAgentSessionLogger, + since?: number ): Promise { try { - await journal.markQueueReopen(fence) + await journal.markQueueReopen(fence, since) } catch (error) { logger.warn('marking a reopened queue failed', { scope: 'queue-reopen-mark', From 70a37e35820f5b65f54746c7cbf2f7911acd08cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:52:09 -0700 Subject: [PATCH 29/31] test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause Main's #24660 test published queuePause 'restarted', which this branch's wire type no longer lists, so the mobile tests typecheck ratchet failed. --- .../use-mobile-structured-agent-session-queued.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 fe7f7f1eb14a..abecc1550c67 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 @@ -580,7 +580,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' } ) ) ) @@ -589,7 +590,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' }) }) }) From 4b769c75f83f6cc5929199344cb98c09dc552aaa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:13:11 -0700 Subject: [PATCH 30/31] refactor(native-chat): settle a restart's leftovers and mark them in one host-lifetime step Keeps the delivery loop under its line limit after main's Stopping change; no behaviour change. --- .../structured-agent-session-delivery-loop.ts | 25 ++++++------------- .../structured-agent-session-host-lifetime.ts | 20 +++++++++++++++ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 7c996ba779bc..b0fbe2b43151 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -20,11 +20,6 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' -import { - markStructuredQueueReopen, - structuredAgentSessionHostInstance -} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { @@ -46,6 +41,7 @@ import { failedProviderChildStart } from './structured-agent-session-provider-ch import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -168,19 +164,12 @@ export class StructuredAgentSessionDeliveryLoop { // The open already did, unless its write failed: a row an earlier handle wrote is never handed // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then // fails, which rejects every queued send, this process's own too. - const { journal } = session - const fence = this.deps.conversationFence(sessionId) - const hostInstance = structuredAgentSessionHostInstance() - const settled = await holdUnsentSends(journal, { - fence, - hostInstance, - hold: { cause: 'hostRestarted' } - }) - if (settled !== null) { - // The open's settle failed, so it marked nothing for the cards this made; a send that woke - // this loop came after them, so the mark starts where they did. - await markStructuredQueueReopen(sessionId, journal, fence, this.deps.logger, settled + 1) - } + await holdRestartedStructuredAgentSessionSends( + this.deps.logger, + sessionId, + session.journal, + this.deps.conversationFence(sessionId) + ) if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 3e9c1460f4b2..8f579eee5dea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -102,6 +102,26 @@ export async function holdClosedStructuredAgentSessionSends( return settled.ok } +/** What an earlier host process left queued and the open could not settle (its write failed): + * kept or rejected now, never handed over. A failure throws. A send that woke this came after the + * ones it settled, so the reopen mark starts where they did. */ +export async function holdRestartedStructuredAgentSessionSends( + logger: StructuredAgentSessionHostDeps['logger'], + sessionId: string, + journal: StructuredAgentSessionHostSession['journal'], + fence: number +): Promise { + const hostInstance = structuredAgentSessionHostInstance() + const settled = await holdUnsentSends(journal, { + fence, + hostInstance, + hold: { cause: 'hostRestarted' } + }) + if (settled !== null) { + await markStructuredQueueReopen(sessionId, journal, fence, logger, settled + 1) + } +} + /** * The agent goes to rest; the conversation stays. Begins the child's close, or joins the one * already begun, and waits for the exit's proof as long as a caller may. Still unproven, it throws From cffa07b83f2d0a0ad67e5863773cdc35a26be3be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:02:09 -0700 Subject: [PATCH 31/31] test(native-chat): a kept card's Resume and failed-mark tests quit through the held start's release Main's #25152 holds the start these tests send into; quitting without releasing it left the quit waiting. --- .../structured-agent-session-unsent-send-hold.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index dea14d4f7758..3f9e272a80f4 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 @@ -208,7 +208,7 @@ describe('the next accepted turn releases a kept card', () => { it('a Resume sends it, and nothing sends it before', async () => { const id = await acceptWhileStarting(sendRequest('resume me')) - await rig.quitRestartHostProcess() + await quitRestart() await new Promise((resolve) => setTimeout(resolve, 100)) expect(rig.dispatch).not.toHaveBeenCalled() expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) @@ -785,7 +785,7 @@ describe("the reopen's mark", () => { const marks = vi .spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') .mockRejectedValueOnce(new Error('disk full')) - await rig.quitRestartHostProcess() + await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) expect(marks).toHaveBeenCalledOnce()