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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 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 26/26] 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' }) }) })