diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 922ddfd20cdf..f6120d50861b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -66,7 +66,8 @@ const NativeChatComposerPane = forwardRef 0, + ...(steerQueued ? { steerQueued } : {}), setActiveSuggestion, setDraft, setCaret, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx index ec6ca5b37344..b01675c63a74 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx @@ -363,6 +363,58 @@ describe('NativeChatMessageList turns from the turn record', () => { expect(follows(screen.getByText('Say DONE'), activity!)).toBe(true) }) + it.each(['settled', 'running'] as const)( + "a queued card Steered into a %s turn joins it, under the opener's bar, with no bar of its own", + (phase) => { + // Steer hands the draft over under a fresh submission id, linked by `queuedMessageId`; the + // host scopes its row to the turn it joined. + const steer = agentJournalSubmissionKey('handed-off') + const running = phase === 'running' + const items = [ + say('u1', 'user', 'List three fruits'), + running + ? item('t1', { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: 'u1', + startedAt: 1_000 + }) + : settledTurn('t1', 'u1', 4, 1_000), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + say(steer, 'user', 'Make it four', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry, date.', inTurn('t1')) + ] + const { container } = render( + journalList( + items, + [ + { + clientMessageId: 'handed-off', + fence: 1, + payloadFingerprint: 'steer', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 2_000, + resolvedAt: 2_100, + queuedMessageId: 'draft-1' + } + ], + running ? { isWorking: true, workingStartedAt: 1_000 } : {} + ) + ) + + const bars = container.querySelectorAll('[data-native-chat-turn-status]') + expect(bars).toHaveLength(1) + const opener = screen.getByText('List three fruits') + const steered = screen.getByText('Make it four') + expect(follows(bars[0]!, opener)).toBe(true) + expect(follows(steered, bars[0]!)).toBe(true) + expect(follows(screen.getByText('Apple, banana, cherry, date.'), steered)).toBe(true) + } + ) + it("keeps a steer on its way into an ordinary running turn above that turn's activity", () => { const steer = agentJournalSubmissionKey('steer') const items = [ diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx new file mode 100644 index 000000000000..c37fd3127b3a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -0,0 +1,214 @@ +import { + AlertCircle, + CornerDownRight, + ListEnd, + MoreHorizontal, + Pencil, + Send, + Trash2 +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { translate } from '@/i18n/i18n' +import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' +import { isMacPlatform } from './native-chat-shortcut' +import 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 { + switch (card.hold) { + case 'returned': { + // Read exactly as a rejected submission: the typed fact decides, the reason is the fallback. + const reason = card.returnedReason ?? null + // A consumed draft whose submission a Stop withdrew is not a failure of the + // message — say what happened rather than "not sent". + if ( + classifyDispatchRejection({ reason, rejection: card.returnedRejection }).category === + 'withdrawn' + ) { + return translate( + 'components.native-chat.queuedMessages.withdrawnHold', + 'Stopped before it was sent' + ) + } + return agentSessionWriteNoticeText( + structuredAgentSessionAttemptFailureParts( + { kind: 'rejected', reason }, + // The card's own Send is the retry, so the words leave out sending again. + { retryControl: true }, + readAgentSessionFailureFact(card.returnedRejection) + ) + ) + } + 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. + if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { + return translate( + 'components.native-chat.queuedMessages.pausedSendFailed', + "Couldn't send — press Send to retry." + ) + } + return translate('components.native-chat.queuedMessages.paused', 'Paused') + case 'behind-returned': + return translate( + 'components.native-chat.queuedMessages.behindReturnedHold', + 'Waiting — a message ahead needs attention' + ) + case 'awaiting-answer': + return translate( + 'components.native-chat.queuedMessages.awaitingAnswerHold', + 'Waiting for your answer' + ) + case 'turn': + case 'queue-paused': + // Plainly queued; a paused queue's header row carries the why. + return 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's ↳, or Send's paper plane. */ + steers: boolean + label: string + hint: string +} { + if (card.hold === 'paused' || card.hold === 'returned') { + return { + steers: false, + label: translate('components.native-chat.queuedMessages.send', 'Send'), + hint: translate('components.native-chat.queuedMessages.sendHint', 'Send this message now') + } + } + return { + steers: true, + label: translate('components.native-chat.queuedMessages.steer', 'Steer'), + hint: translate( + 'components.native-chat.queuedMessages.steerHint', + 'Submit without interrupting the model' + ) + } +} + +export function NativeChatQueuedMessageCard({ + card, + showsSteerShortcut, + onSteer, + onDelete, + onEdit, + onTurnOffQueueing +}: { + card: QueuedMessageCard + /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ + showsSteerShortcut: boolean + onSteer: () => void + onDelete: () => void + onEdit: () => void + onTurnOffQueueing: () => void +}): React.JSX.Element { + const caption = queuedMessageCardCaption(card) + const returned = card.state === 'returned' + const sendNow = queuedMessageCardSendNow(card) + const isMac = isMacPlatform() + return ( +
  • + {returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? ( + + ) : ( + + )} +
    +

    + {card.text} +

    + {caption ? ( +

    + {caption} +

    + ) : null} +
    + + + + + + + {sendNow.hint} + {showsSteerShortcut ? ( + + ) : null} + + + + + + + + + {translate('components.native-chat.queuedMessages.delete', 'Delete')} + + + + + + + + + + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} + + + {translate( + 'components.native-chat.queuedMessages.turnOffQueueing', + 'Turn off queueing' + )} + + + +
  • + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx new file mode 100644 index 000000000000..c681a3902f99 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -0,0 +1,454 @@ +// @vitest-environment happy-dom + +// The card stack above the composer: an accessible, labeled live list whose +// rows expose Steer/Send, Delete, and the Edit / Turn-off-queueing menu, with +// captions derived client-side per state. + +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + updateSettings: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('../../store', () => { + const state = { updateSettings: mocks.updateSettings } + const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) + +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' + +function renderList(owner: StructuredAgentSessionQueuedMessagesController) { + // The app root mounts the provider; tests supply the same context. + return render( + + + + ) +} + +function card(overrides: Partial & { messageId: string }): QueuedMessageCard { + return { + position: 1, + text: `text of ${overrides.messageId}`, + state: 'waiting', + hold: 'turn', + ...overrides + } +} + +function controller( + cards: QueuedMessageCard[], + pause: { reason: string } | null = null +): 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 () => {}), + steerNewest: vi.fn(() => false) + } +} + +beforeEach(() => { + mocks.updateSettings.mockReset() +}) + +afterEach(cleanup) + +describe('NativeChatQueuedMessageList', () => { + it('renders only an empty live region when the host holds no drafts', () => { + const { container } = renderList(controller([])) + expect(screen.queryByRole('list')).toBeNull() + expect(container.querySelector('[aria-live="polite"]')?.childElementCount).toBe(0) + }) + + it('the first card appears inside a live region that was already mounted', () => { + const { container, rerender } = renderList(controller([])) + const region = container.querySelector('[aria-live="polite"]') + rerender( + + + + ) + expect(container.querySelector('[aria-live="polite"]')).toBe(region) + expect(region?.contains(screen.getByRole('list', { name: 'Queued messages' }))).toBe(true) + }) + + it('is a labeled list with one row per draft, in order', () => { + renderList( + controller([ + card({ messageId: 'draft-1', position: 1 }), + card({ messageId: 'draft-2', position: 2 }) + ]) + ) + screen.getByRole('list', { name: 'Queued messages' }) + const rows = screen.getAllByRole('listitem') + expect(rows).toHaveLength(2) + expect(rows[0]?.textContent).toContain('text of draft-1') + expect(rows[1]?.textContent).toContain('text of draft-2') + }) + + it('Steer and Delete are named buttons wired to their card', () => { + const owner = controller([ + card({ messageId: 'draft-1', position: 1 }), + card({ messageId: 'draft-2', position: 2 }) + ]) + renderList(owner) + fireEvent.click(screen.getAllByRole('button', { name: 'Steer' })[0]!) + expect(owner.steer).toHaveBeenCalledWith('draft-1') + fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[1]!) + expect(owner.remove).toHaveBeenCalledWith('draft-2') + }) + + it.each(['Delete', 'Steer'])( + '%s hands focus to the composer once the focused card is gone', + async (name) => { + const focusComposer = vi.fn() + const owner = controller([card({ messageId: 'draft-1', position: 1 })]) + render( + + + + ) + const action = screen.getByRole('button', { name }) + action.focus() + fireEvent.click(action) + await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) + } + ) + + it('a returned card shows the stored reason and offers Send instead of Steer', () => { + const owner = controller([ + card({ + messageId: 'refused', + state: 'returned', + hold: 'returned', + // Internal marker: never shown verbatim, exactly as for rejected submissions. + returnedReason: 'host_restarted_before_delivery' + }) + ]) + renderList(owner) + expect(screen.getByRole('listitem').textContent).toContain('Your message was not sent.') + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(owner.steer).toHaveBeenCalledWith('refused') + expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() + }) + + it('a returned card is worded from its typed fact, exactly as a rejected submission', () => { + renderList( + controller([ + card({ + messageId: 'restarted', + state: 'returned', + hold: 'returned', + returnedReason: 'host_restarted_before_delivery', + returnedRejection: { kind: 'hostRestarted' } + }) + ]) + ) + const row = screen.getByRole('listitem') + expect(row.textContent).toContain('Orca restarted before this message was sent.') + expect(row.textContent).not.toContain('Your message was not sent.') + }) + + it('the words leave out sending again: the card offers its own Send', () => { + renderList( + controller([ + card({ + messageId: 'undelivered', + state: 'returned', + hold: 'returned', + returnedReason: 'not_delivered', + returnedRejection: { kind: 'notDelivered' } + }) + ]) + ) + const row = screen.getByRole('listitem') + expect(row.textContent).toContain('This message was not delivered.') + expect(row.textContent).not.toContain('Send it again') + }) + + it("a Stop's withdrawal is read from the fact, whatever sentence the reason carries", () => { + renderList( + controller([ + card({ + messageId: 'stopped', + state: 'returned', + hold: 'returned', + returnedReason: 'This message was withdrawn before the agent started it.', + returnedRejection: { kind: 'cancelled' } + }) + ]) + ) + expect(screen.getByRole('listitem').textContent).toContain('Stopped before it was sent') + }) + + it("a provider's own refusal words are shown verbatim", () => { + renderList( + controller([ + card({ + messageId: 'refused', + state: 'returned', + hold: 'returned', + returnedReason: 'The active turn cannot be steered during review.' + }) + ]) + ) + expect(screen.getByRole('listitem').textContent).toContain( + 'The active turn cannot be steered during review.' + ) + }) + + 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' } + ) + ) + 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( + controller([ + card({ messageId: 'waiting' }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 2 }) + ]) + ) + const [waiting, failed] = screen.getAllByRole('listitem') + expect( + within(waiting!) + .getByRole('button', { name: 'Steer' }) + .querySelector('.lucide-corner-down-right') + ).not.toBeNull() + expect(waiting!.firstElementChild?.classList.contains('lucide-list-end')).toBe(true) + expect( + within(failed!).getByRole('button', { name: 'Send' }).querySelector('.lucide-send') + ).not.toBeNull() + expect(failed!.firstElementChild?.classList.contains('lucide-circle-alert')).toBe(true) + expect(container.querySelectorAll('.lucide-list-end')).toHaveLength(1) + }) + + 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 }) + ], + { 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', 'divide-y']) + ) + // 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) + for (const row of rows) { + expect(row.className.split(' ')).not.toContain('border') + expect(row.className.split(' ')).not.toContain('rounded-md') + } + // Captions still render on their own rows. + 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', () => { + for (const count of [1, 4]) { + const view = renderList( + controller( + Array.from({ length: count }, (_, index) => + card({ messageId: `draft-${index}`, position: index }) + ) + ) + ) + const list = screen.getByRole('list') + expect(list.parentElement?.children).toHaveLength(1) + expect(within(list).getAllByRole('listitem')).toHaveLength(count) + view.unmount() + } + }) + + it('the Steer tooltip spaces its hint from the shortcut chips', async () => { + renderList(controller([card({ messageId: 'newest' })])) + fireEvent.focus(screen.getByRole('button', { name: 'Steer' })) + const hint = await screen.findAllByText('Submit without interrupting the model') + const group = hint[0]!.parentElement! + expect(group.tagName).toBe('SPAN') + expect(group.className).toBe('flex items-center gap-2') + 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' })], { + reason: 'stopped' + }) + renderList(owner) + const row = screen.getByRole('listitem') + 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) + fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + expect(owner.steer).toHaveBeenCalledWith('waiting') + }) + + it('only a card still waiting on the turn promises to skip the wait', () => { + for (const hold of ['turn', 'awaiting-answer', 'behind-returned'] as const) { + 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 }))).toEqual({ + steers: false, + label: 'Send', + hint: 'Send this message now' + }) + } + }) + + it("the host's pause and withdrawal markers localize instead of rendering raw", () => { + renderList( + controller([ + card({ + messageId: 'failed-consume', + hold: 'paused', + pausedReason: 'send_failed' + }), + card({ + messageId: 'stopped', + state: 'returned', + hold: 'returned', + position: 2, + // A cancellation confirmed after Stop settled renders as an ordinary card. + returnedReason: 'provider_cancelled_before_start' + }) + ]) + ) + const rows = screen.getAllByRole('listitem') + expect(rows[0]?.textContent).toContain("Couldn't send — press Send to retry.") + expect(rows[0]?.textContent).not.toContain('send_failed') + expect(rows[1]?.textContent).toContain('Stopped before it was sent') + // Still a normal returned card: Send and Delete stay offered. + expect(screen.getAllByRole('button', { name: 'Send' }).length).toBeGreaterThan(0) + }) + + it('an absent or unknown pause marker reads as a plain pause, never raw', () => { + renderList( + controller([ + card({ messageId: 'future', hold: 'paused', pausedReason: 'some_newer_marker' }), + card({ messageId: 'bare', hold: 'paused', position: 2 }) + ]) + ) + for (const row of screen.getAllByRole('listitem')) { + expect(row.querySelectorAll('p')[1]?.textContent).toBe('Paused') + } + expect(screen.getAllByRole('listitem')[0]?.textContent).not.toContain('some_newer_marker') + }) + + it('a draft behind a returned card says a message ahead needs attention', () => { + renderList(controller([card({ messageId: 'behind', hold: 'behind-returned' })])) + expect(screen.getByRole('listitem').textContent).toContain( + 'Waiting — a message ahead needs attention' + ) + }) + + it('the menu offers Edit message and Turn off queueing', async () => { + const owner = controller([card({ messageId: 'draft-1' })]) + renderList(owner) + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit message' })) + expect(owner.edit).toHaveBeenCalledWith('draft-1') + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Turn off queueing' })) + expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx new file mode 100644 index 000000000000..17223e0194cb --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -0,0 +1,123 @@ +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' +import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' + +/** + * Host-held drafts stacked between the transcript and the composer — never in + * the transcript: a draft only becomes a bubble once the host consumes it into + * a submission. The live region stays mounted while empty, so the first card is announced. + */ +export function NativeChatQueuedMessageList({ + controller, + focusComposer +}: { + controller: StructuredAgentSessionQueuedMessagesController + /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ + focusComposer?: () => void +}): React.JSX.Element { + const updateSettings = useAppStore((store) => store.updateSettings) + 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. + const refocusAfter = (action: Promise): void => { + void action.then(() => { + const active = document.activeElement + if (!active || active === document.body || queueRef.current?.contains(active)) { + focusComposer?.() + } + }) + } + return ( +
    + {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} +
      + {cards.map((card) => ( + refocusAfter(controller.steer(card.messageId))} + onDelete={() => refocusAfter(controller.remove(card.messageId))} + onEdit={() => refocusAfter(controller.edit(card.messageId))} + onTurnOffQueueing={() => void updateSettings({ nativeChatQueueFollowUps: false })} + /> + ))} +
    +
    +
    + ) : 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/NativeChatStructuredSession.goal-dock.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx index c3c08f6647e0..7b90b912a3f8 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx @@ -207,3 +207,36 @@ describe('NativeChatStructuredSession task strip above a pending prompt', () => ) }) }) + +describe('NativeChatStructuredSession queued messages above the task strip', () => { + afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() + }) + + it('stacks queued messages above the strip, so running shells and agents sit next to the composer', () => { + showStripAndGoal() + mocks.queuedCards = [ + { + messageId: 'draft-1', + position: 1, + text: 'Reply with the word banana.', + state: 'waiting', + hold: 'turn' + } + ] + render(sessionView()) + + const queued = document.querySelector('[data-queued-message-id="draft-1"]') + const strip = document.querySelector(STRIP) + if (!queued || !strip) { + throw new Error('expected both a queued message and the task strip') + } + expect(queued.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + // The strip still docks on the goal tab; the queue never sits between them. + expect(strip.nextElementSibling).toBe(document.querySelector(GOAL)) + }) +}) 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 71fd557983ae..29dd7f4bdf05 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -1,6 +1,7 @@ import { forwardRef, useImperativeHandle, useRef } from 'react' import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' @@ -112,7 +113,12 @@ export function createStructuredSessionMocks() { hasOlder: false, loadingOlder: false, olderHistoryGeneration: 0, - loadOlder: vi.fn<() => Promise>() + loadOlder: vi.fn<() => Promise>(), + queuedCards: Array.of(), + 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) } const moduleFactories = { @@ -184,6 +190,13 @@ export function createStructuredSessionMocks() { turnId: mocks.turnId, canStop: mocks.canStop ?? mocks.turnId !== null, stop: mocks.stop, + queuedMessages: { + cards: mocks.queuedCards, + steer: mocks.queuedSteer, + remove: mocks.queuedRemove, + edit: mocks.queuedEdit, + steerNewest: mocks.queuedSteerNewest + }, threadGoal: mocks.threadGoal, cancel: mocks.cancel, stopBackgroundTask: (taskId?: string) => diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 935d247309a5..170114213434 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -23,6 +23,8 @@ import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-a import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' +import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' +import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' @@ -47,9 +49,12 @@ export function NativeChatStructuredSession( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] ) + // Chat-wide: absent means on; only an explicit off keeps mid-turn sends immediate. + const queueFollowUps = useAppStore((store) => store.settings?.nativeChatQueueFollowUps !== false) const controller = useStructuredAgentSession({ ...props, composerScopeKey: paneKey, + queueFollowUps, providerStarting: hostExecution.phase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) @@ -296,6 +301,13 @@ export function NativeChatStructuredSession( agentLabel={agentLabel} onRetry={provisionalLaunch.retry} /> + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} + { + composerRef.current?.focus() + }} + /> void controller.stop()} + steerQueued={controller.queuedMessages.steerNewest} 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 81a8f53300f1..3f778fbd8cea 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -90,6 +90,13 @@ vi.mock('./use-structured-agent-session', async () => { backgroundTasks: mocks.backgroundTasks, turnId: null, cancel: vi.fn(), + queuedMessages: { + cards: [], + steer: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + edit: vi.fn(async () => {}), + steerNewest: () => false + }, stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId), respond: mocks.respond, optionSnapshot: [ 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 516c7e0a8bb5..ec90f81fe083 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 @@ -66,6 +66,9 @@ export type NativeChatComposerProps = { launchSeed?: NativeChatLaunchSeed /** Structured journal transport; absent keeps the existing PTY path unchanged. */ structuredTransport?: NativeChatStructuredComposerTransport + /** 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 } /** Launch context prefilled into the TUI input as an unsent draft, plus the two diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index faadac1127ef..6ccc98b45267 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -141,6 +141,17 @@ describe('the notice on each message that did not go through', () => { }) }) + it('never says a send attempted before a Stop was not sent: the host may hold it', () => { + const interrupted = entry('stopped', { state: 'queued', lastAttemptAt: 5, outlivedStop: true }) + expect(texts([interrupted], 'stopped')).toEqual({ + [agentJournalSubmissionKey('stopped')]: 'Message delivery is unconfirmed.' + }) + const neverSent = entry('unsent', { state: 'queued', outlivedStop: true }) + expect(texts([neverSent], 'unsent')).toEqual({ + [agentJournalSubmissionKey('unsent')]: 'Message was not sent.' + }) + }) + // The drain's own rule: a message behind the one the queue stopped on is only waiting, so it says // nothing. A rejected message holds nothing up and keeps its words. it('says why on the message the queue stopped on and on every rejected one', () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index ec3eb60e6875..cccc3d7de3d3 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -72,7 +72,10 @@ function deliveryNoticeText( recorded: AgentJournalSubmission | undefined, startFailures: readonly AgentSessionFailureFact[] ): string { - if (entry.state === 'unconfirmed') { + // A send attempted before a Stop and then interrupted may already be with the host. + const attemptedAcrossStop = + entry.outlivedStop === true && entry.lastAttemptAt !== null && !entry.lastFailure + if (entry.state === 'unconfirmed' || attemptedAcrossStop) { return translate( 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', 'Message delivery is unconfirmed.' diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts new file mode 100644 index 000000000000..ef5fc10aa941 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts @@ -0,0 +1,40 @@ +// An owner change sends a send left dispatching again under its id. One a Stop outlived is put +// back the same way, and its mark alone holds it: the drain never admits it. + +import { describe, expect, it } from 'vitest' +import { + admitStructuredAgentSessionOutboxEntry, + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { requeueInterruptedStructuredAgentSessionDispatches } from './structured-agent-session-outbox-dispatch' + +function dispatching( + clientMessageId: string, + overrides: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + state: 'dispatching', + lastAttemptAt: 2, + ...overrides + } +} + +describe('requeue after an owner change', () => { + it('resends an interrupted send, but never one a Stop outlived', () => { + const [stopped] = requeueInterruptedStructuredAgentSessionDispatches( + [dispatching('stopped', { outlivedStop: true })], + 1 + ) + expect(admitStructuredAgentSessionOutboxEntry([stopped], null).state).toBe('blocked') + const [plain] = requeueInterruptedStructuredAgentSessionDispatches([dispatching('plain')], 1) + expect(admitStructuredAgentSessionOutboxEntry([plain], null).state).toBe('dispatch') + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index c42121a2b5c7..bcfbad91c776 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -57,7 +57,8 @@ export function readMountedStructuredAgentSessionOutbox( ) } -/** A send left dispatching when its owner changed goes out again, under the same id. */ +/** A send left dispatching when its owner changed goes out again, under the same id; one a Stop + * outlived is held from there by its mark, until the user's Retry. */ export function requeueInterruptedStructuredAgentSessionDispatches( entries: StructuredAgentSessionOutboxEntry[], fence: number | null diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts new file mode 100644 index 000000000000..f543efcacd8e --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts @@ -0,0 +1,75 @@ +// The user's Retry on a stuck outbox entry: requeue it, rotating the operation +// id when the recorded one can only ever replay a settled rejection. + +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' + +export function retryStructuredAgentSessionOutboxEntry(args: { + clientMessageId: string + sessionId: string + submissions: readonly AgentJournalSubmission[] + outboxRef: { current: StructuredAgentSessionOutboxEntry[] } + setOutbox: (entries: StructuredAgentSessionOutboxEntry[]) => void + setError: (error: string | null) => void + createOperationId: () => string +}): void { + const { clientMessageId, outboxRef, sessionId, setError, setOutbox, submissions } = args + const submission = submissions.find((candidate) => candidate.clientMessageId === clientMessageId) + const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) + // The host settled this id as rejected, and reusing it only replays that forever, so rotate the + // id for a safe resend. Read from the message itself, which outlives a restart, or from a + // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that + // settled the message already rotated it. + const recordedRejection = + current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' + if (current && (recordedRejection || submission?.dispatchState === 'rejected')) { + const rotated = outboxRef.current.map((entry) => + entry.clientMessageId === clientMessageId + ? { + ...retriedByUser(entry), + clientMessageId: args.createOperationId(), + state: 'queued' as const, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null + } + : entry + ) + if (!writeOutbox(sessionId, rotated)) { + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = rotated + setOutbox(rotated) + return + } + const retryAfterUnknownSubmittedAt = + submission?.dispatchState === 'unknown' + ? submission.submittedAt + : current?.state === 'unconfirmed' + ? -1 + : null + const next = outboxRef.current.map((entry) => + entry.clientMessageId === clientMessageId + ? { + ...retriedByUser(entry), + state: 'queued' as const, + retryAfterUnknownSubmittedAt + } + : entry + ) + if (!writeOutbox(sessionId, next)) { + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = next + setOutbox(next) +} + +/** The user's own Retry is what a Stop left the entry waiting for. */ +function retriedByUser({ + outlivedStop: _retried, + ...entry +}: StructuredAgentSessionOutboxEntry): StructuredAgentSessionOutboxEntry { + return entry +} 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 new file mode 100644 index 000000000000..db229531ec9c --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -0,0 +1,233 @@ +// Card projection policy: queue order, derived hold labels (the wire carries +// none), 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' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + newestSteerableQueuedMessageCard, + outboxOutsideQueuedCards, + projectQueuedMessageCards +} from './structured-agent-session-queued-cards' + +function draft( + id: string, + position: number, + overrides: Partial = {} +): AgentSessionQueuedMessage { + return { + messageId: id, + position, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `text of ${id}` }] }, + state: 'waiting', + ...overrides + } +} + +function submission( + clientMessageId: string, + dispatchState: AgentJournalSubmission['dispatchState'] = 'pending', + queuedMessageId?: string +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + ...(queuedMessageId !== undefined ? { queuedMessageId } : {}) + } +} + +/** The host's hand-off of draft `draftId`, always under a submission id of its own. */ +function handOff( + draftId: string, + dispatchState: AgentJournalSubmission['dispatchState'] = 'pending', + id = `${draftId}-hand-off` +): AgentJournalSubmission { + return submission(id, dispatchState, draftId) +} + +const IDLE = { hasPendingPrompt: false } +const QUEUEING = { capability: 'supported', enabled: true } as const + +describe('queued message cards', () => { + it('orders by host position whatever order the list arrives in', () => { + const cards = projectQueuedMessageCards([draft('b', 2), draft('a', 1), draft('c', 3)], [], IDLE) + expect(cards.map((card) => card.messageId)).toEqual(['a', 'b', 'c']) + expect(cards[0]?.text).toBe('text of a') + }) + + it('holds: waiting defaults to the turn, a pending prompt changes the caption', () => { + expect(projectQueuedMessageCards([draft('a', 1)], [], IDLE)[0]?.hold).toBe('turn') + expect( + projectQueuedMessageCards([draft('a', 1)], [], { hasPendingPrompt: true })[0]?.hold + ).toBe('awaiting-answer') + }) + + it('a returned card carries its stored reason and blocks the label of drafts behind it', () => { + const cards = projectQueuedMessageCards( + [ + draft('failed', 1, { + state: 'returned', + returnedReason: 'agent_session_write_failed', + returnedRejection: { kind: 'writeFailed' } + }), + draft('behind', 2) + ], + [], + IDLE + ) + expect(cards[0]).toMatchObject({ + state: 'returned', + hold: 'returned', + returnedReason: 'agent_session_write_failed', + returnedRejection: { kind: 'writeFailed' } + }) + expect(cards[1]?.hold).toBe('behind-returned') + }) + + it('a paused draft says so, carrying the host marker for the caption to localize', () => { + const cards = projectQueuedMessageCards( + [draft('a', 1, { paused: true, pausedReason: 'send_failed' })], + [], + IDLE + ) + expect(cards[0]).toMatchObject({ hold: 'paused', pausedReason: 'send_failed' }) + }) + + it('shows a draft a Stop put back: its rejected hand-off is what sent it back', () => { + // The Stop pause belongs to the queue, so the draft itself carries no hold. + const requeued = draft('requeued', 1) + expect( + projectQueuedMessageCards([requeued], [handOff('requeued', 'rejected')], IDLE) + ).toMatchObject([{ messageId: 'requeued', state: 'waiting' }]) + for (const dispatchState of ['pending', 'accepted'] as const) { + expect( + projectQueuedMessageCards([requeued], [handOff('requeued', dispatchState)], IDLE) + ).toEqual([]) + } + }) + + it('requeued and drained again under a fresh id, the card and the bubble never show together', () => { + // A multi-page catch-up: the new hand-off's bubble arrives before the list that drops the card. + const cards = projectQueuedMessageCards( + [draft('requeued', 1)], + [handOff('requeued', 'rejected', 'first'), handOff('requeued', 'pending', 'second')], + IDLE + ) + expect(cards).toEqual([]) + }) + + it('hides a waiting card only by the link, never by a submission id that equals the draft id', () => { + const cards = projectQueuedMessageCards( + [ + draft('consumed', 1), + draft('kept', 2), + draft('refused', 3, { state: 'returned', returnedReason: null }) + ], + [handOff('consumed'), submission('kept'), handOff('refused', 'rejected')], + IDLE + ) + expect(cards.map((card) => card.messageId)).toEqual(['kept', 'refused']) + }) + + it('a paused queue outranks a pending prompt: an answer does not drain it', () => { + const cards = projectQueuedMessageCards( + [ + draft('waiting', 1), + draft('failed', 2, { paused: true, pausedReason: 'send_failed' }), + draft('refused', 3, { state: 'returned', returnedReason: null }), + draft('behind', 4) + ], + [], + { hasPendingPrompt: true, queuePaused: true } + ) + expect(cards.map((card) => card.hold)).toEqual([ + 'queue-paused', + 'paused', + 'returned', + 'behind-returned' + ]) + expect( + projectQueuedMessageCards([draft('waiting', 1)], [], { hasPendingPrompt: true })[0]?.hold + ).toBe('awaiting-answer') + }) + + it('steers the newest card', () => { + const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) + expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') + expect(newestSteerableQueuedMessageCard([])).toBeNull() + }) + + it('a mid-turn queue send on its way is no bubble; one that stalled stays visible', () => { + const entry = ( + clientMessageId: string, + overrides: Partial = {} + ): StructuredAgentSessionOutboxEntry => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null, + ...overrides + }) + const ids = (entries: readonly StructuredAgentSessionOutboxEntry[]): string[] => + entries.map((candidate) => candidate.clientMessageId) + const inFlight = [ + entry('a', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: 'queue-if-active' }), + entry('b') + ] + expect(ids(outboxOutsideQueuedCards(inFlight, [], true, null, QUEUEING))).toEqual([]) + expect(ids(outboxOutsideQueuedCards(inFlight, [], false, null, QUEUEING))).toEqual(['a', 'b']) + // Refused and held for Retry: its text, and everything waiting behind it, stays in view. + const refused = [entry('a', { lastFailure: { kind: 'failed' } }), entry('b')] + expect(ids(outboxOutsideQueuedCards(refused, [], true, 'a', QUEUEING))).toEqual(['a', 'b']) + // A rejected send holds nothing up: what follows it is still on its way to a card. + const rejected = [ + entry('a', { state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }), + entry('b') + ] + expect(ids(outboxOutsideQueuedCards(rejected, [], true, null, QUEUEING))).toEqual(['a']) + const unconfirmed = [entry('a', { state: 'unconfirmed' }), entry('b')] + expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, null, QUEUEING))).toEqual(['a', 'b']) + // Once the host visibly holds it, it is a card whatever this queue last heard. + expect(ids(outboxOutsideQueuedCards(unconfirmed, ['a'], true, null, QUEUEING))).toEqual(['b']) + }) + + it('hides a send only by what its request carries: a plain one is always a bubble', () => { + const entry = ( + clientMessageId: string, + overrides: Partial = {} + ): StructuredAgentSessionOutboxEntry => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null, + ...overrides + }) + const ids = (entries: readonly StructuredAgentSessionOutboxEntry[]): string[] => + entries.map((candidate) => candidate.clientMessageId) + // The capability is unknown: a new send goes out plain, so it stays in view. + const unknown = { capability: 'unknown', enabled: true } as const + expect(ids(outboxOutsideQueuedCards([entry('new')], [], true, null, unknown))).toEqual(['new']) + // A send that went out plain stays a bubble; one that went out queued replays queued. + const sent = [ + entry('plain', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: null }), + entry('queued', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: 'queue-if-active' }) + ] + expect(ids(outboxOutsideQueuedCards(sent, [], true, null, unknown))).toEqual(['plain']) + }) +}) 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 new file mode 100644 index 000000000000..8973adf5fc69 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -0,0 +1,127 @@ +// What the queued-message cards above the composer show, derived per publish — +// the wire carries no hold label (§ labels are client policy, not host state). + +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 { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' +import { + structuredAgentSessionEntryAsksToQueue, + type StructuredAgentSessionQueueDelivery +} from '../../../../shared/structured-agent-session-outbox-delivery' +import { + admitStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' + +/** 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. */ + | 'queue-paused' + | 'awaiting-answer' + | 'paused' + | 'behind-returned' + | 'returned' + +export type QueuedMessageCard = { + messageId: string + position: number + /** The draft's text blocks joined; drafts are text-only in v1. */ + text: string + state: 'waiting' | 'returned' + hold: QueuedMessageCardHold + pausedReason?: string + returnedReason?: string | null + /** The typed fact the returned card's submission settled with; read like its `rejection`. */ + returnedRejection?: UnreadAgentSessionFailureFact +} + +function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string { + return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') +} + +/** + * Cards in queue order. A waiting card is hidden once a submission hands it off + * (`queuedMessageId`) and that hand-off is live: on a multi-page catch-up the shrunk + * list rides only the final page, so the bubble and the card would otherwise briefly + * coexist. A rejected hand-off is exactly what sent the draft back, so it hides + * nothing. Presentation only — no durable state. Returned cards never hide. + */ +export function projectQueuedMessageCards( + queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, + submissions: readonly AgentJournalSubmission[], + session: { hasPendingPrompt: boolean; queuePaused?: boolean } +): QueuedMessageCard[] { + const handedOff = handedOffQueuedMessageIds( + submissions.filter((submission) => submission.dispatchState !== 'rejected') + ) + const ordered = [...(queuedMessages ?? [])] + .sort((left, right) => left.position - right.position) + .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) + let behindReturned = false + return ordered.map((message) => { + const hold: QueuedMessageCardHold = + message.state === 'returned' + ? 'returned' + : message.paused + ? 'paused' + : behindReturned + ? 'behind-returned' + : session.queuePaused + ? 'queue-paused' + : session.hasPendingPrompt + ? 'awaiting-answer' + : 'turn' + behindReturned = behindReturned || message.state === 'returned' + return { + messageId: message.messageId, + position: message.position, + text: queuedMessageCardText(message.body), + state: message.state, + hold, + ...(message.pausedReason !== undefined ? { pausedReason: message.pausedReason } : {}), + ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), + ...(message.returnedRejection !== undefined + ? { returnedRejection: message.returnedRejection } + : {}) + } + }) +} + +/** The card Cmd/Ctrl+Enter steers: the newest one; every shown card takes Send-now. */ +export function newestSteerableQueuedMessageCard( + cards: readonly QueuedMessageCard[] +): QueuedMessageCard | null { + return cards.at(-1) ?? null +} + +/** + * The outbox entries the transcript may show as pending bubbles. A send the host holds + * as a draft (same id) is a card, and so is a send on its way out asking to be queued — + * read from what its request carries — otherwise it paints + * in the transcript until the queued answer retires it. A plain send stays a bubble. From + * the entry the drain is stopped on (read through the drain's own rule), nothing is on its + * way: those stay bubbles so their text is visible beside the Retry row. + */ +export function outboxOutsideQueuedCards( + outbox: readonly StructuredAgentSessionOutboxEntry[], + heldIds: readonly string[], + isWorking: boolean, + blockedClientMessageId: string | null, + host: StructuredAgentSessionQueueDelivery +): readonly StructuredAgentSessionOutboxEntry[] { + const held = new Set(heldIds) + const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) + const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 + const next = outbox.filter((entry, index) => { + const onItsWay = + isWorking && + (stalledFrom === -1 || index < stalledFrom) && + (entry.state === 'queued' || entry.state === 'dispatching') && + structuredAgentSessionEntryAsksToQueue(entry, host) + return !held.has(entry.clientMessageId) && !onItsWay + }) + return next.length === outbox.length ? outbox : next +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index fb8a0c202e58..c5bf4600aacb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -60,8 +60,15 @@ export function useStructuredAgentSessionWithdrawnRestore( return useMemo( () => ({ byHost: (entries, submissions) => { + // A hand-off of a queued draft is never restored: the Stop that withdrew it put the draft + // back as a card, which carries the text. const withdrawn = new Set( - submissions.filter(dispatchWasWithdrawn).map((submission) => submission.clientMessageId) + submissions + .filter( + (submission) => + dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined + ) + .map((submission) => submission.clientMessageId) ) restoreWithdrawnMessages( sessionId, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx new file mode 100644 index 000000000000..3317c6d6247c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom + +// Cmd/Ctrl+Enter in an empty composer: steer the newest queued draft when one +// exists; with nothing queued, or with text or images in the composer, the +// chord stays what it always was — a send. +// The primary modifier follows the platform (⌘ on Mac, Ctrl elsewhere). + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { EMPTY_HISTORY } from './native-chat-composer-state' + +const platform = vi.hoisted(() => ({ isMac: true })) +vi.mock('./native-chat-shortcut', () => ({ + isMacPlatform: () => platform.isMac +})) + +import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' + +function setup( + steerQueued?: () => boolean, + composer: { draft?: string; hasAttachments?: boolean } = {} +) { + const callbacks = { + completePickerItem: vi.fn(), + dispatchPickerCommand: vi.fn(), + dismissPicker: vi.fn(), + interrupt: vi.fn(), + send: vi.fn(), + setActiveSuggestion: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + setHistory: vi.fn() + } + const hook = renderHook(() => + useNativeChatComposerKeyDown({ + autocomplete: { mode: 'none' as const }, + activeSuggestion: 0, + draft: composer.draft ?? '', + hasAttachments: composer.hasAttachments ?? false, + history: EMPTY_HISTORY, + isComposing: () => false, + ...(steerQueued ? { steerQueued } : {}), + ...callbacks + }) + ) + return { handler: hook.result.current, callbacks } +} + +function enter(modifiers: Partial<{ metaKey: boolean; ctrlKey: boolean }> = {}) { + return { + key: 'Enter', + shiftKey: false, + metaKey: false, + ctrlKey: false, + keyCode: 0, + nativeEvent: { isComposing: false }, + preventDefault: vi.fn(), + ...modifiers + } +} + +type Handler = ReturnType + +function press(handler: Handler, event: ReturnType): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only key, modifiers, keyCode, nativeEvent and preventDefault, all present on this stub. + handler(event as unknown as Parameters[0]) +} + +describe('composer steer chord', () => { + it('⌘+Enter on Mac steers the newest queued draft instead of sending', () => { + platform.isMac = true + const steerQueued = vi.fn(() => true) + const { handler, callbacks } = setup(steerQueued) + const event = enter({ metaKey: true }) + press(handler, event) + expect(steerQueued).toHaveBeenCalledTimes(1) + expect(callbacks.send).not.toHaveBeenCalled() + expect(event.preventDefault).toHaveBeenCalled() + }) + + it('Ctrl — not ⌘ — is the chord off Mac', () => { + platform.isMac = false + const steerQueued = vi.fn(() => true) + const { handler, callbacks } = setup(steerQueued) + press(handler, enter({ metaKey: true })) + expect(steerQueued).not.toHaveBeenCalled() + expect(callbacks.send).toHaveBeenCalledTimes(1) + press(handler, enter({ ctrlKey: true })) + expect(steerQueued).toHaveBeenCalledTimes(1) + expect(callbacks.send).toHaveBeenCalledTimes(1) + }) + + it('with nothing queued the chord falls through to a plain send', () => { + platform.isMac = true + const { handler, callbacks } = setup(vi.fn(() => false)) + press(handler, enter({ metaKey: true })) + expect(callbacks.send).toHaveBeenCalledTimes(1) + }) + + it('with text or an image in the composer the chord sends it, never a card past it', () => { + platform.isMac = true + const steerQueued = vi.fn(() => true) + const typed = setup(steerQueued, { draft: 'typed text' }) + press(typed.handler, enter({ metaKey: true })) + expect(steerQueued).not.toHaveBeenCalled() + expect(typed.callbacks.send).toHaveBeenCalledTimes(1) + const image = setup(steerQueued, { hasAttachments: true }) + press(image.handler, enter({ metaKey: true })) + expect(steerQueued).not.toHaveBeenCalled() + expect(image.callbacks.send).toHaveBeenCalledTimes(1) + }) + + it('plain Enter never steers', () => { + platform.isMac = true + const steerQueued = vi.fn(() => true) + const { handler, callbacks } = setup(steerQueued) + press(handler, enter()) + expect(steerQueued).not.toHaveBeenCalled() + expect(callbacks.send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index b89aa88c5ea7..6c5c84df0a4b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -6,11 +6,14 @@ import { type HistoryState, type NativeChatPickerItem } from './native-chat-composer-state' +import { isMacPlatform } from './native-chat-shortcut' export type UseNativeChatComposerKeyDownArgs = { autocomplete: ComposerAutocomplete activeSuggestion: number draft: string + /** Image chips count as composer content, like typed text. */ + hasAttachments?: boolean history: HistoryState isComposing: () => boolean completePickerItem: (item: NativeChatPickerItem) => void @@ -18,6 +21,9 @@ export type UseNativeChatComposerKeyDownArgs = { dismissPicker: (triggerKey: string) => void interrupt: () => void send: () => void + /** Cmd/Ctrl+Enter from an empty composer: send the newest queued draft now; false falls + * through to send. */ + steerQueued?: (() => boolean) | undefined setActiveSuggestion: Dispatch> setDraft: Dispatch> setCaret: Dispatch> @@ -28,6 +34,7 @@ export function useNativeChatComposerKeyDown({ autocomplete, activeSuggestion, draft, + hasAttachments = false, history, isComposing, completePickerItem, @@ -35,6 +42,7 @@ export function useNativeChatComposerKeyDown({ dismissPicker, interrupt, send, + steerQueued, setActiveSuggestion, setDraft, setCaret, @@ -92,6 +100,14 @@ export function useNativeChatComposerKeyDown({ return } if (event.key === 'Enter' && !event.shiftKey) { + // Platform primary modifier only (AGENTS.md): ⌘ on Mac, Ctrl elsewhere. + const steerChord = isMacPlatform() ? event.metaKey : event.ctrlKey + // Only from an empty composer: the chord never sends a card past what the user just wrote. + const composerEmpty = draft.trim() === '' && !hasAttachments + if (steerChord && composerEmpty && steerQueued?.()) { + event.preventDefault() + return + } event.preventDefault() send() return @@ -123,10 +139,12 @@ export function useNativeChatComposerKeyDown({ dismissPicker, dispatchPickerCommand, draft, + hasAttachments, history, interrupt, isComposing, send, + steerQueued, setActiveSuggestion, setCaret, setDraft, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts new file mode 100644 index 000000000000..8a2c30b82794 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts @@ -0,0 +1,78 @@ +// Who owns an outbox entry's text when it leaves this client's queue without a +// send answer. A Stop hands unsent text back to the composer — a local move; no +// text crosses a wire. A host that visibly holds the entry as a queued draft +// (same id) owns it: those entries retire with no local restore, so the same +// words can never come back twice. + +import { useCallback, useEffect } from 'react' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { withdrawUnsentStructuredAgentSessionOutboxEntries } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' +import { writeOutbox } from './structured-agent-session-outbox-storage' +import type { useStructuredAgentSessionWithdrawnRestore } from './structured-agent-session-withdrawn-message-restore' + +export function useStructuredAgentSessionOutboxOwnership(args: { + sessionId: string + submissions: readonly AgentJournalSubmission[] + /** Ids of the host's published drafts; an entry with one of these ids is host-owned. */ + queuedMessageIds: readonly string[] | undefined + outboxRef: { current: StructuredAgentSessionOutboxEntry[] } + blockedIdRef: { current: string | null } + /** The send in flight and its generation: a host that holds that send answered it. */ + inFlightIdRef: { current: string | null } + dispatchGenerationRef: { current: number } + setOutbox: (entries: StructuredAgentSessionOutboxEntry[]) => void + restoreWithdrawn: ReturnType +}): { + /** Stop's local step, before its RPC, so the drain has nothing left to send after it. */ + withdrawUnsent: () => void +} { + const { blockedIdRef, outboxRef, queuedMessageIds, restoreWithdrawn, sessionId, setOutbox } = args + const { dispatchGenerationRef, inFlightIdRef, submissions } = args + + const withdrawUnsent = useCallback((): void => { + const next = withdrawUnsentStructuredAgentSessionOutboxEntries( + outboxRef.current, + submissions, + blockedIdRef.current, + inFlightIdRef.current + ) + const current = outboxRef.current + if (next.length === current.length && next.every((entry, index) => entry === current[index])) { + return + } + // By id: a kept entry comes back marked, as a new object. + const kept = new Set(next.map((entry) => entry.clientMessageId)) + restoreWithdrawn.byStop(current.filter((entry) => !kept.has(entry.clientMessageId))) + outboxRef.current = next + setOutbox(next) + writeOutbox(sessionId, next) + }, [blockedIdRef, inFlightIdRef, outboxRef, restoreWithdrawn, sessionId, setOutbox, submissions]) + + // Drop host-owned entries without a restore: the published card is the text now. + const retire = useCallback( + (ids: readonly string[]): void => { + const owned = new Set(ids) + // Like a journal row answering it: free single-flight and void the unsettled send's reply. + if (inFlightIdRef.current !== null && owned.has(inFlightIdRef.current)) { + dispatchGenerationRef.current += 1 + inFlightIdRef.current = null + } + const next = outboxRef.current.filter((entry) => !owned.has(entry.clientMessageId)) + if (next.length !== outboxRef.current.length) { + outboxRef.current = next + setOutbox(next) + writeOutbox(sessionId, next) + } + }, + [dispatchGenerationRef, inFlightIdRef, outboxRef, sessionId, setOutbox] + ) + + useEffect(() => { + if (queuedMessageIds && queuedMessageIds.length > 0) { + retire(queuedMessageIds) + } + }, [queuedMessageIds, retire]) + + return { withdrawUnsent } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts index e4e49c119755..6c53f6b88ddf 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts @@ -39,9 +39,12 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { // array-identity dep would reset the backoff forever while the agent is working. // A non-null `retryAfterUnknownSubmittedAt` means the user already retried, so // another request would repeat that explicit action. Only entries that have - // never been retried are safe to probe automatically. + // never been retried, and that no Stop outlived, are safe to probe automatically. const probeId = - blocker && blocker.sessionId === sessionId && blocker.retryAfterUnknownSubmittedAt === null + blocker && + blocker.sessionId === sessionId && + blocker.retryAfterUnknownSubmittedAt === null && + blocker.outlivedStop !== true ? blocker.clientMessageId : null const probeSettled = diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx new file mode 100644 index 000000000000..19c2c48206bc --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx @@ -0,0 +1,207 @@ +// @vitest-environment happy-dom + +// A submission's `queuedMessageId` is the only link between a queued send and what the host did +// with it: the host hands every draft off under a fresh submission id. An outbox entry the host +// handed off belongs to the host, whatever the hand-off's state, and nothing here compares a draft +// id with a submission id. + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' + +type SentParams = { envelope: { clientOperationId: string } } + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: SentParams) => Promise>() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' + +const TARGET = { kind: 'local' } as const +const QUEUEING = { capability: 'supported', enabled: true } as const + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 11, + handoverRecorded: true, + ...overrides + } +} + +const WITHDRAWN = { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED } as const + +function seed(clientMessageId: string, queued: boolean): void { + writeOutbox('session-1', [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: 'follow-up', + attachments: [], + queuedAt: 1 + }), + sentDelivery: queued ? ('queue-if-active' as const) : null, + state: 'unconfirmed', + lastAttemptAt: 5 + } + ]) +} + +function renderOutbox() { + type Props = { submissions: AgentJournalSubmission[] } + const initialProps: Props = { submissions: [] } + return renderHook( + (props: Props) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: TARGET, + fence: 1, + submissions: props.submissions, + composerScopeKey: 'scope', + queueDelivery: QUEUEING + }), + { initialProps } + ) +} + +beforeEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() + mocks.call.mockReset() + mocks.call.mockImplementation(() => new Promise(() => {})) +}) + +afterEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() +}) + +describe('an outbox entry the host handed off as a queued draft', () => { + it('lost answer, Stop, then drained under a fresh id: it leaves, and the composer gets nothing', async () => { + seed('draft', true) + const view = renderOutbox() + act(() => { + view.result.current.withdrawUnsent() + }) + expect(view.result.current.outbox).toHaveLength(1) + view.rerender({ + submissions: [ + submission('first', { ...WITHDRAWN, queuedMessageId: 'draft' }), + submission('second', { queuedMessageId: 'draft' }) + ] + }) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readNativeChatDraftCache('scope')).toBe('') + }) + + it('lost answer, then refused: the returned card carries it, with no Retry row here', async () => { + seed('draft', true) + const view = renderOutbox() + // No published list yet: the link alone settles it, whichever effect runs first. + view.rerender({ + submissions: [ + submission('first', { + dispatchState: 'rejected', + reason: 'The provider refused this message.', + queuedMessageId: 'draft' + }) + ] + }) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(view.result.current.blockedClientMessageId).toBeNull() + expect(readNativeChatDraftCache('scope')).toBe('') + }) + + it('a withdrawn immediate send still comes back to the composer', async () => { + seed('plain', false) + const view = renderOutbox() + view.rerender({ submissions: [submission('plain', WITHDRAWN)] }) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readNativeChatDraftCache('scope')).toBe('follow-up') + }) + + it('a withdrawn hand-off is never restored, even one under the id of the entry', async () => { + seed('draft', true) + const view = renderOutbox() + view.rerender({ submissions: [submission('draft', { ...WITHDRAWN, queuedMessageId: 'q' })] }) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readNativeChatDraftCache('scope')).toBe('') + }) + + it('a replayed send answered with the hand-off settles as the host holding it', async () => { + mocks.call.mockImplementationOnce(async (_target, _method, params) => ({ + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: 'second', + submission: submission('second', { + dispatchState: 'pending', + resolvedAt: null, + queuedMessageId: params.envelope.clientOperationId + }) + } + })) + const view = renderOutbox() + act(() => { + expect(view.result.current.send('follow-up')).toBe(true) + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + // Let the answer land before reading the outbox. + await act(async () => new Promise((resolve) => setTimeout(resolve, 20))) + expect(view.result.current.outbox).toEqual([]) + expect(view.result.current.error).toBeNull() + // Single-flight is free again: the next send goes out. + expect(view.result.current.send('next')).toBe(true) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + }) + + it('a replay of a deleted card is answered spent: it leaves with no restore and no Retry', async () => { + mocks.call.mockImplementationOnce(async (_target, _method, params) => ({ + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: params.envelope.clientOperationId, + queued: { messageId: params.envelope.clientOperationId, position: 0, state: 'withdrawn' } + } + })) + const view = renderOutbox() + act(() => { + expect(view.result.current.send('deleted on another device')).toBe(true) + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + await act(async () => new Promise((resolve) => setTimeout(resolve, 20))) + expect(view.result.current.outbox).toEqual([]) + expect(view.result.current.blockedClientMessageId).toBeNull() + expect(view.result.current.error).toBeNull() + expect(readNativeChatDraftCache('scope')).toBe('') + act(() => { + expect(view.result.current.send('next')).toBe(true) + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx new file mode 100644 index 000000000000..7bc986279aaf --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx @@ -0,0 +1,319 @@ +// @vitest-environment happy-dom + +// What the wire sees when queueing is available: `delivery` rides the send and +// its operation fingerprint only for a capable host with the setting on, a +// `queued` answer spends the entry, and everything else is byte-for-byte +// today's request — an older host must never see the key at all. + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' +import type { StructuredAgentSessionQueueCapability } from '../../../../shared/structured-agent-session-outbox-delivery' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' + +type SentParams = { + envelope: { clientOperationId: string; sessionId: string; payloadFingerprint: string } + body?: unknown + delivery?: 'queue-if-active' +} + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: SentParams) => Promise>() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { readOutbox } from './structured-agent-session-outbox-storage' + +const LOCAL_TARGET = { kind: 'local' } as const +const QUEUEING = { capability: 'supported', enabled: true } as const + +function queuedReceipt(clientMessageId: string) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId, + queued: { messageId: clientMessageId, position: 1, state: 'waiting' as const } + } + } +} + +function renderOutbox(queue: boolean) { + return renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + queueDelivery: queue ? QUEUEING : { capability: 'unsupported', enabled: true } + }) + ) +} + +async function sentParams(): Promise { + await waitFor(() => expect(mocks.call).toHaveBeenCalled()) + const call = mocks.call.mock.calls[0] + expect(call?.[1]).toBe('agentSession.send') + const params = call?.[2] + if (!params) { + throw new Error('no send left the outbox') + } + return params +} + +beforeEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() + mocks.call.mockReset() +}) + +afterEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() +}) + +describe('outbox queue delivery selection', () => { + it('stamps `delivery` on the send and its operation fingerprint when queueing is on', async () => { + mocks.call.mockImplementation(async (_target, _method, params) => + queuedReceipt(params.envelope.clientOperationId) + ) + const { result } = renderOutbox(true) + expect(result.current.send('queue me')).toBe(true) + const params = await sentParams() + expect(params.delivery).toBe('queue-if-active') + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: params.body, delivery: 'queue-if-active' } + }) + ) + // The host owns the draft now: the queued answer spends the outbox entry. + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + }) + + it("sends exactly today's request when the host lacks the capability or the setting is off", async () => { + mocks.call.mockImplementation(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: 'ignored', + submission: { + clientMessageId: 'ignored', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: 'provider-1', + reason: null, + submittedAt: 1, + resolvedAt: 1 + } + } + })) + const { result } = renderOutbox(false) + expect(result.current.send('plain send')).toBe(true) + const params = await sentParams() + expect('delivery' in params).toBe(false) + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: params.body } + }) + ) + }) + + it('routes an image send down the immediate path even with queueing on (text-only v1)', async () => { + mocks.call.mockImplementation(async () => queuedReceipt('unused')) + const { result } = renderOutbox(true) + expect( + result.current.send('with image', [{ path: '/tmp/a.png', previewUri: 'file:///tmp/a.png' }]) + ).toBe(true) + const params = await sentParams() + expect('delivery' in params).toBe(false) + }) + + it('retires an entry the host visibly holds as a draft, with no second copy of the text', async () => { + // A queued send whose acknowledgement was lost: the entry survives under the + // draft's own id, then the published draft list proves the host owns it. + mocks.call.mockImplementation(() => new Promise(() => {})) + const first = renderHook( + (props: { queuedMessageIds: string[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + queueDelivery: { capability: 'supported' as const, enabled: true }, + queuedMessageIds: props.queuedMessageIds + }), + { initialProps: { queuedMessageIds: Array.of() } } + ) + expect(first.result.current.send('lost ack')).toBe(true) + await waitFor(() => expect(mocks.call).toHaveBeenCalled()) + const entryId = mocks.call.mock.calls[0]?.[2]?.envelope.clientOperationId + expect(entryId).toBeTruthy() + expect(first.result.current.outbox).toHaveLength(1) + first.rerender({ queuedMessageIds: [entryId ?? ''] }) + await waitFor(() => expect(first.result.current.outbox).toHaveLength(0)) + // Retired, not restored: nothing to resend, nothing appended anywhere. + expect(localStorage.getItem('orca:desktopStructuredAgentSessionOutbox:v1:session-1')).toBeNull() + // The held draft answered the send, so the next one goes without waiting on the lost reply. + expect(first.result.current.send('next')).toBe(true) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + }) + + it("Stop's local step never restores a queued send already in flight — its answer settles it", async () => { + // The send is on its way; the Stop lands behind it, so the host may already hold + // it as a paused card. Restoring it locally too would double the text. + mocks.call.mockImplementation(() => new Promise(() => {})) + const view = renderHook( + (props: { queuedMessageIds: string[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + composerScopeKey: 'stop-scope', + queueDelivery: { capability: 'supported' as const, enabled: true }, + queuedMessageIds: props.queuedMessageIds + }), + { initialProps: { queuedMessageIds: Array.of() } } + ) + expect(view.result.current.send('issued text')).toBe(true) + await waitFor(() => expect(mocks.call).toHaveBeenCalled()) + // A second send waits behind single-flight: the Stop still owns ITS text locally. + expect(view.result.current.send('never left')).toBe(true) + act(() => { + view.result.current.withdrawUnsent() + }) + // The unissued entry came back to the composer; the issued one stayed put. + expect(readNativeChatDraftCache('stop-scope')).toBe('never left') + // The issued one waits for its answer, marked: only the user's Retry sends it again. + expect(view.result.current.outbox.map((entry) => [entry.state, entry.outlivedStop])).toEqual([ + ['dispatching', true] + ]) + // The host publishes the issued send as a card: retired, still nothing restored. + const entryId = mocks.call.mock.calls[0]?.[2]?.envelope.clientOperationId + view.rerender({ queuedMessageIds: [entryId ?? ''] }) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readNativeChatDraftCache('stop-scope')).toBe('never left') + }) + + it('while the capability is unknown, an attempted queue send replays what it sent', async () => { + const view = await attemptedQueueSend() + view.rerender({ capability: 'unknown' }) + const id = view.result.current.outbox[0]?.clientMessageId ?? '' + act(() => { + view.result.current.retry(id) + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + const params = mocks.call.mock.calls[1]?.[2] + expect(params?.envelope.clientOperationId).toBe(id) + expect(params?.delivery).toBe('queue-if-active') + expect(params?.envelope.payloadFingerprint).toBe( + mocks.call.mock.calls[0]?.[2]?.envelope.payloadFingerprint + ) + }) + + it('while the capability is unknown, a new send goes out plain and holds up nothing', async () => { + mocks.call.mockImplementation(async (_target, _method, params) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: params.envelope.clientOperationId, + submission: { + clientMessageId: params.envelope.clientOperationId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: 'provider-1', + reason: null, + submittedAt: 1, + resolvedAt: 1 + } + } + })) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + queueDelivery: { capability: 'unknown', enabled: true } + }) + ) + act(() => { + result.current.send('first') + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + act(() => { + result.current.send('second') + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + for (const call of mocks.call.mock.calls) { + expect('delivery' in call[2]).toBe(false) + } + }) + + it('a host known not to queue gets the Retry without `delivery`; the entry keeps what it sent', async () => { + // Such a host rejects the strict field before its operation ledger, so a replay carrying it + // again could only fail the same way. + const view = await attemptedQueueSend() + view.rerender({ capability: 'unsupported' }) + const id = view.result.current.outbox[0]?.clientMessageId ?? '' + act(() => { + view.result.current.retry(id) + }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + const params = mocks.call.mock.calls[1]?.[2] + expect(params?.envelope.clientOperationId).toBe(id) + expect(params && 'delivery' in params).toBe(false) + expect(params?.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: params?.body } + }) + ) + expect(readOutbox('session-1')[0]?.sentDelivery).toBe('queue-if-active') + }) +}) + +const SUPPORTED: StructuredAgentSessionQueueCapability = 'supported' + +/** A queue send whose first attempt failed and now waits on the user's Retry. */ +async function attemptedQueueSend() { + mocks.call.mockImplementationOnce(async () => { + throw new Error('invalid_argument: Unrecognized key: "delivery"') + }) + const view = renderHook( + (props: { capability: StructuredAgentSessionQueueCapability }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + queueDelivery: { capability: props.capability, enabled: true } + }), + { initialProps: { capability: SUPPORTED } } + ) + expect(view.result.current.send('follow-up')).toBe(true) + await waitFor(() => expect(view.result.current.blockedClientMessageId).not.toBeNull()) + expect(mocks.call.mock.calls[0]?.[2]?.delivery).toBe('queue-if-active') + mocks.call.mockImplementation(() => new Promise(() => {})) + return view +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx new file mode 100644 index 000000000000..c9609913fab8 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom + +// A queued follow-up whose answer is out survives a Stop, since the host may hold it as a paused +// card. From then on only the user's Retry sends it: the unconfirmed probe resending it onto the +// now-idle session would start a turn the user just stopped. + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' + +type SentParams = { envelope: { clientOperationId: string }; delivery?: string } + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: SentParams) => Promise>() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' + +const TARGET = { kind: 'local' } as const +const REMOTE = { kind: 'environment', environmentId: 'env-1' } as const +const NOT_ATTACHED: { fence: number | null } = { fence: null } + +beforeEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() + mocks.call.mockReset() + mocks.call.mockImplementation(() => new Promise(() => {})) + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.useRealTimers() + localStorage.clear() +}) + +describe('a Stop with a queued send in doubt', () => { + it('a remote send probed back to queued after a lost answer: Stop keeps it for Retry', async () => { + // Lost answer, a re-probe that failed (capability unknown), and the probe flipped the entry + // back to `queued`; the Stop lands before the drain sends it again. + writeOutbox('session-1', [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'probed', + sessionId: 'session-1', + text: 'follow-up', + attachments: [], + queuedAt: 1 + }), + sentDelivery: 'queue-if-active', + lastAttemptAt: 5, + state: 'queued' + } + ]) + const view = renderHook( + (props: { fence: number | null }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: REMOTE, + fence: props.fence, + submissions: [], + composerScopeKey: 'scope', + queueDelivery: { capability: 'unknown', enabled: true } + }), + { initialProps: NOT_ATTACHED } + ) + act(() => { + view.result.current.withdrawUnsent() + }) + // The host may hold it as a paused card: the composer gets nothing. + expect(readNativeChatDraftCache('scope')).toBe('') + // Marked, state untouched: the mark alone holds it. + expect(view.result.current.outbox.map((entry) => [entry.state, entry.outlivedStop])).toEqual([ + ['queued', true] + ]) + view.rerender({ fence: 1 }) + // Past the probe's longest backoff several times over. + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(mocks.call).not.toHaveBeenCalled() + expect(readOutbox('session-1').map((entry) => entry.clientMessageId)).toEqual(['probed']) + + act(() => { + view.result.current.retry('probed') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + // The same operation with what it first sent, though the capability is still unknown. + expect(mocks.call.mock.calls.map((call) => call[2].envelope.clientOperationId)).toEqual([ + 'probed' + ]) + expect(mocks.call.mock.calls[0]?.[2].delivery).toBe('queue-if-active') + }) + + it('never resends one a Stop found in flight whose answer later comes back unknown', async () => { + const answer = Promise.withResolvers() + mocks.call.mockImplementationOnce(() => answer.promise) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: TARGET, + fence: 1, + submissions: [], + composerScopeKey: 'scope', + queueDelivery: { capability: 'supported' as const, enabled: true } + }) + ) + act(() => { + result.current.send('follow-up') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(mocks.call).toHaveBeenCalledTimes(1) + const id = mocks.call.mock.calls[0]?.[2].envelope.clientOperationId ?? '' + act(() => { + result.current.withdrawUnsent() + }) + // The transport loses the answer: delivery unknown, after the Stop. + await act(async () => { + answer.reject(new Error('connection closed')) + await vi.advanceTimersByTimeAsync(0) + }) + expect(result.current.outbox.map((entry) => entry.state)).toEqual(['unconfirmed']) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(mocks.call).toHaveBeenCalledTimes(1) + + act(() => { + result.current.retry(id) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(mocks.call.mock.calls.map((call) => call[2].envelope.clientOperationId)).toEqual([ + id, + id + ]) + }) + + it('the drain never sends one a Stop outlived, even after a reload left it queued', async () => { + writeOutbox('session-1', [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'outlived', + sessionId: 'session-1', + text: 'follow-up', + attachments: [], + queuedAt: 1 + }), + sentDelivery: 'queue-if-active', + lastAttemptAt: 5, + outlivedStop: true, + state: 'queued' + } + ]) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: TARGET, + fence: 1, + submissions: [], + queueDelivery: { capability: 'supported', enabled: true } + }) + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(mocks.call).not.toHaveBeenCalled() + // Held by the mark alone; its state, and so its notice, stays what its answer made it. + expect(result.current.outbox.map((entry) => entry.state)).toEqual(['queued']) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 117f22dd9a66..569b707f761e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -4,10 +4,8 @@ import { createStructuredAgentSessionOperationId } from '../../../../shared/stru import { admitStructuredAgentSessionOutboxEntry, createStructuredAgentSessionOutboxEntry, - reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { withdrawUnsentStructuredAgentSessionOutboxEntries } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import { journalAnswersInFlightSend, type StructuredAgentSessionSendDisposition @@ -24,6 +22,21 @@ import { useStructuredAgentSessionOutboxOwnerChange } from '@/runtime/structured import { useStructuredAgentSessionOutboxUnconfirmedProbe } from './use-structured-agent-session-outbox-unconfirmed-probe' import { createBrowserUuid } from '@/lib/browser-uuid' import { useStructuredAgentSessionWithdrawnRestore } from './structured-agent-session-withdrawn-message-restore' +import { useStructuredAgentSessionOutboxOwnership } from './use-structured-agent-session-outbox-ownership' +import { + handedOffQueuedMessageIds, + reconcileStructuredAgentSessionOutboxWithQueue +} from '../../../../shared/structured-agent-session-draft-hand-off' +import { + structuredAgentSessionEntryAttempt, + type StructuredAgentSessionQueueDelivery +} from '../../../../shared/structured-agent-session-outbox-delivery' +import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' + +const NO_QUEUE_DELIVERY: StructuredAgentSessionQueueDelivery = { + capability: 'unsupported', + enabled: false +} export function structuredSessionOperationId(): string { return createStructuredAgentSessionOperationId(createBrowserUuid) @@ -36,8 +49,24 @@ export function useStructuredAgentSessionOutbox(args: { submissions: readonly AgentJournalSubmission[] /** The composer that gets back what a Stop withdrew from this client's outbox. */ composerScopeKey?: string + /** The host's queued-messages capability and the user's setting; a send stamped + * `delivery: 'queue-if-active'` is held as a draft only while the agent is working. */ + queueDelivery?: StructuredAgentSessionQueueDelivery + /** Ids of the host's published drafts. A queued send whose acknowledgement was lost keeps + * its entry here under the draft's own id; once the host visibly holds the draft, the + * entry retires so the same text can never come back twice. */ + queuedMessageIds?: readonly string[] }) { - const { composerScopeKey, fence, sessionId, submissions, target } = args + const { + composerScopeKey, + fence, + queueDelivery = NO_QUEUE_DELIVERY, + queuedMessageIds, + sessionId, + submissions, + target + } = args + const { capability: queueCapability, enabled: queueEnabled } = queueDelivery // What resends, unblocks and drops a send in flight besides a Retry or a new send; see the hook. const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey) @@ -90,15 +119,16 @@ export function useStructuredAgentSessionOutbox(args: { useEffect(() => { const current = outboxRef.current - const hostOwns = new Set( - submissions + const hostOwns = new Set([ + ...submissions .filter( (submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'accepted' ) - .map((submission) => submission.clientMessageId) - ) - const next = reconcileStructuredAgentSessionOutbox(current, submissions) + .map((submission) => submission.clientMessageId), + ...handedOffQueuedMessageIds(submissions) + ]) + const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions) const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current) if ( admittedInFlight || @@ -190,10 +220,15 @@ export function useStructuredAgentSessionOutbox(args: { setOutbox(persisted) return } + // The request reads the capability; the entry keeps only what its first attempt sent. + const attempt = structuredAgentSessionEntryAttempt(persistedEntry, { + capability: queueCapability, + enabled: queueEnabled + }) const dispatchGeneration = dispatchGenerationRef.current const dispatch = dispatchStructuredAgentSessionOutboxEntry({ - next: persistedEntry, - persisted, + next: attempt.wire, + persisted: persisted.map((entry) => (entry === persistedEntry ? attempt.stored : entry)), sessionId, target, fence, @@ -212,7 +247,7 @@ export function useStructuredAgentSessionOutbox(args: { // local state, so mirror the settled state once the shared admission finishes. void dispatch.promise.then(mirrorPersisted) } - }, [applyDisposition, fence, outbox, sessionId, target]) + }, [applyDisposition, fence, outbox, queueCapability, queueEnabled, sessionId, target]) useStructuredAgentSessionOutboxUnconfirmedProbe({ sessionId, @@ -228,6 +263,7 @@ export function useStructuredAgentSessionOutbox(args: { if (!text.trim() && attachments.length === 0) { return false } + // Whether it asks to be queued is decided when it first goes out. const entry = createStructuredAgentSessionOutboxEntry({ clientMessageId: structuredSessionOperationId(), sessionId, @@ -248,21 +284,17 @@ export function useStructuredAgentSessionOutbox(args: { [sessionId] ) - // Before the Stop goes out, so the drain has nothing left to send after it. - const withdrawUnsent = useCallback((): void => { - const next = withdrawUnsentStructuredAgentSessionOutboxEntries( - outboxRef.current, - submissions, - blockedIdRef.current, - inFlightIdRef.current - ) - if (next.length !== outboxRef.current.length) { - restoreWithdrawn.byStop(outboxRef.current.filter((entry) => !next.includes(entry))) - outboxRef.current = next - setOutbox(next) - writeOutbox(sessionId, next) - } - }, [restoreWithdrawn, sessionId, submissions]) + const { withdrawUnsent } = useStructuredAgentSessionOutboxOwnership({ + sessionId, + submissions, + queuedMessageIds, + outboxRef, + blockedIdRef, + inFlightIdRef, + dispatchGenerationRef, + setOutbox, + restoreWithdrawn + }) const retry = (clientMessageId: string): void => { // Another message's Retry must not send the one the queue is held on. @@ -270,57 +302,15 @@ export function useStructuredAgentSessionOutbox(args: { blockedIdRef.current = null } setError(null) - const submission = submissions.find( - (candidate) => candidate.clientMessageId === clientMessageId - ) - const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) - // The host settled this id as rejected, and reusing it only replays that forever, so rotate the - // id for a safe resend. Read from the message itself, which outlives a restart, or from a - // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that - // settled the message already rotated it. - const recordedRejection = - current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' - if (current && (recordedRejection || submission?.dispatchState === 'rejected')) { - const rotated = outboxRef.current.map((entry) => - entry.clientMessageId === clientMessageId - ? { - ...entry, - clientMessageId: structuredSessionOperationId(), - state: 'queued' as const, - lastAttemptAt: null, - retryAfterUnknownSubmittedAt: null - } - : entry - ) - if (!writeOutbox(sessionId, rotated)) { - setError('Message could not be saved to the outbox') - return - } - outboxRef.current = rotated - setOutbox(rotated) - return - } - const retryAfterUnknownSubmittedAt = - submission?.dispatchState === 'unknown' - ? submission.submittedAt - : current?.state === 'unconfirmed' - ? -1 - : null - const next = outboxRef.current.map((entry) => - entry.clientMessageId === clientMessageId - ? { - ...entry, - state: 'queued' as const, - retryAfterUnknownSubmittedAt - } - : entry - ) - if (!writeOutbox(sessionId, next)) { - setError('Message could not be saved to the outbox') - return - } - outboxRef.current = next - setOutbox(next) + retryStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId, + submissions, + outboxRef, + setOutbox, + setError, + createOperationId: structuredSessionOperationId + }) } return { outbox, 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..fa4ae91b1eae --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -0,0 +1,125 @@ +// @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.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx new file mode 100644 index 000000000000..5fdfc0a95c7a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom + +// The queued-message controller: which RPC each card action issues, and Edit's +// copy-first order — the card's shown text is in the composer before the Delete +// goes out, so no RPC outcome can lose it. + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +import { toast } from 'sonner' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' +import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' + +type MutateCall = [string, string, Record] + +function body(text: string): AgentSessionQueuedMessage['body'] { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function draft(id: string, position: number): AgentSessionQueuedMessage { + return { messageId: id, position, body: body(`text of ${id}`), state: 'waiting' } +} + +const SCOPE = 'tab-1:pane-scope' + +function createHarness( + overrides: { + queuedMessages?: AgentSessionQueuedMessage[] + enabled?: boolean + composerScopeKey?: string | undefined + mutateResult?: (call: MutateCall) => unknown + } = {} +) { + const mutateCalls: MutateCall[] = [] + const mutate = vi.fn(async (...call: MutateCall) => { + mutateCalls.push(call) + return overrides.mutateResult ? overrides.mutateResult(call) : null + }) + const rendered = renderHook(() => + useStructuredAgentSessionQueuedMessages({ + enabled: overrides.enabled ?? true, + queuedMessages: overrides.queuedMessages ?? [draft('draft-1', 1), draft('draft-2', 2)], + queuePause: null, + submissions: [], + hasPendingPrompt: 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 + }) + ) + return { ...rendered, mutate, mutateCalls } +} + +beforeEach(() => { + clearNativeChatDraftCacheForTests() +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('queued message actions', () => { + it('Steer sends the named draft through queuedMessageSend', async () => { + const harness = createHarness() + await act(() => harness.result.current.steer('draft-1')) + expect(harness.mutate).toHaveBeenCalledWith( + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageSend', + { messageId: 'draft-1' } + ) + }) + + it('Cmd/Ctrl+Enter steers the newest card and reports when there is none', async () => { + const harness = createHarness() + expect(harness.result.current.steerNewest()).toBe(true) + await waitFor(() => + expect(harness.mutate).toHaveBeenCalledWith( + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageSend', + { messageId: 'draft-2' } + ) + ) + const empty = createHarness({ queuedMessages: [] }) + expect(empty.result.current.steerNewest()).toBe(false) + const disabled = createHarness({ enabled: false }) + expect(disabled.result.current.steerNewest()).toBe(false) + }) + + it('Delete withdraws through queuedMessageDelete without touching the composer', async () => { + const harness = createHarness() + await act(() => harness.result.current.remove('draft-1')) + expect(harness.mutate).toHaveBeenCalledWith( + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessageDelete', + { messageId: 'draft-1' } + ) + expect(readNativeChatDraftCache(SCOPE)).toBe('') + }) + + it('Edit puts the shown text in the composer BEFORE the Delete goes out, then deletes', async () => { + let draftWhenDeleteArrived: string | null = null + const harness = createHarness({ + mutateResult: (call) => { + draftWhenDeleteArrived = readNativeChatDraftCache(SCOPE) + return { deleted: true, messageId: call[2].messageId } + } + }) + await act(() => harness.result.current.edit('draft-1')) + expect(harness.mutateCalls).toEqual([ + [ + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessageDelete', + { messageId: 'draft-1' } + ] + ]) + // Copy-first: the composer already held the text when the RPC was issued. + expect(draftWhenDeleteArrived).toBe('text of draft-1') + expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1') + }) + + it('the text survives a failed Delete: the card stays and the composer keeps the copy', async () => { + // mutate answers null for a refused or lost write; the card remains on the host's list. + const harness = createHarness() + await act(() => harness.result.current.edit('draft-1')) + expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1') + expect(harness.result.current.cards.map((card) => card.messageId)).toEqual([ + 'draft-1', + 'draft-2' + ]) + }) + + it('an Edit whose draft was already dispatched says so, and the copy stays', async () => { + const harness = createHarness({ + mutateResult: (call) => ({ + deleted: false, + messageId: call[2].messageId, + disposition: 'dispatched' + }) + }) + await act(() => harness.result.current.edit('draft-1')) + expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1') + expect(toast.error).toHaveBeenCalledWith('Already sent — your text is still in the composer.') + }) + + it('Edit with no composer to hold the text deletes nothing', async () => { + const harness = createHarness({ composerScopeKey: undefined }) + await act(() => harness.result.current.edit('draft-1')) + expect(harness.mutate).not.toHaveBeenCalled() + }) + + it('a second press on a card while its action is in flight sends nothing more', async () => { + const harness = createHarness() + await act(async () => { + await Promise.all([ + harness.result.current.steer('draft-1'), + harness.result.current.steer('draft-1') + ]) + }) + expect(harness.mutate).toHaveBeenCalledTimes(1) + }) +}) 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 new file mode 100644 index 000000000000..4596fb67180f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -0,0 +1,182 @@ +// 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. + +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' +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { + newestSteerableQueuedMessageCard, + projectQueuedMessageCards, + type QueuedMessageCard +} from './structured-agent-session-queued-cards' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' + +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 + /** Copy the card's shown text into the composer, then delete the draft. */ + edit: (messageId: string) => Promise + /** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */ + steerNewest: () => boolean +} + +function alreadySentNotice(): void { + toast.error( + translate('components.native-chat.queuedMessages.alreadySent', 'This message was already sent.') + ) +} + +export function useStructuredAgentSessionQueuedMessages(args: { + /** Host advertises `agent-session.queued-messages.v1` and the transport has a fence. */ + enabled: boolean + queuedMessages: readonly AgentSessionQueuedMessage[] | null + queuePause: AgentSessionQueuePause | null + submissions: readonly AgentJournalSubmission[] + hasPendingPrompt: boolean + composerScopeKey: string | undefined + mutate: StructuredAgentSessionMutate +}): StructuredAgentSessionQueuedMessagesController { + const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args + const pause = args.queuePause + + const cards = useMemo( + () => + projectQueuedMessageCards(queuedMessages, submissions, { + hasPendingPrompt, + queuePaused: pause !== null + }), + [hasPendingPrompt, pause, queuedMessages, submissions] + ) + const cardsRef = useRef(cards) + useEffect(() => { + cardsRef.current = cards + }, [cards]) + + // One action per card at a time: a double-click or a chord repeat is not a second request. + const actingOnRef = useRef(new Set()) + const actOnce = useCallback( + async (messageId: string, action: () => Promise): Promise => { + if (actingOnRef.current.has(messageId)) { + return + } + actingOnRef.current.add(messageId) + try { + await action() + } finally { + actingOnRef.current.delete(messageId) + } + }, + [] + ) + + const steer = useCallback( + (messageId: string): Promise => + actOnce(messageId, async () => { + await mutate( + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageSend', + { messageId } + ) + }), + [actOnce, mutate] + ) + + const remove = useCallback( + (messageId: string): Promise => + actOnce(messageId, async () => { + const result = await mutate( + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessageDelete', + { messageId } + ) + if (result && !result.deleted && result.disposition === 'dispatched') { + alreadySentNotice() + } + }), + [actOnce, mutate] + ) + + const edit = useCallback( + (messageId: string): Promise => + actOnce(messageId, async () => { + // The text is copied FIRST, from the card this pane already shows — a local move, + // never a wire payload. Without a composer to hold it, deleting would destroy it, + // so the draft then stays a card. + const card = cardsRef.current.find((entry) => entry.messageId === messageId) + if (!card || !composerScopeKey) { + return + } + appendNativeChatDraftCache(composerScopeKey, card.text) + const result = await mutate( + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessageDelete', + { messageId } + ) + if (result && !result.deleted && result.disposition === 'dispatched') { + toast.error( + translate( + 'components.native-chat.queuedMessages.editAlreadySent', + 'Already sent — your text is still in the composer.' + ) + ) + } + // A failed Delete leaves the card: the text shows in both places, visibly, never lost. + }), + [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 + } + const newest = newestSteerableQueuedMessageCard(cardsRef.current) + if (!newest) { + return false + } + void steer(newest.messageId) + return true + }, [enabled, steer]) + + return { cards, pause, resume, resuming, steer, remove, edit, steerNewest } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index b08801b199b6..a2d58aabcb44 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -39,6 +39,9 @@ export function useStructuredAgentSessionTransportState( isWorking, turnActivity, turnTiming, + // null = no drafts or no claim; the projection treats both as an empty list. + queuedMessages: (enabled ? state.queuedMessages : null) ?? null, + queuePause: (enabled ? state.queuePause : null) ?? null, backgroundTasks: structuredSessionBackgroundTasksView( enabled ? state.backgroundTasks : null, turnId diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx new file mode 100644 index 000000000000..cd4de967f5d8 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -0,0 +1,345 @@ +// @vitest-environment happy-dom + +// Capability gating for mid-turn queueing at the session controller: only a +// host advertising `agent-session.queued-messages.v1` gets `delivery` or the +// card RPCs — anything older sees exactly today's client. Stop and /clear are +// today's plain writes for every host: drafts are never withdrawn by either, +// and no draft text ever rides an answer. A host-held draft is a card above +// the composer, never a transcript bubble. + +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 { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + outboxArgs: Array.of<{ queueDelivery?: { capability: string; enabled: boolean } }>(), + operations: 0 +})) +let items: AgentJournalRenderItem[] = [] +let queuedMessages: AgentSessionQueuedMessage[] | undefined +let outboxEntries: StructuredAgentSessionOutboxEntry[] = [] + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + ...(queuedMessages !== undefined ? { queuedMessages } : {}) + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: () => `operation-${++mocks.operations}`, + useStructuredAgentSessionOutbox: (args: { + queueDelivery?: { capability: string; enabled: boolean } + }) => { + mocks.outboxArgs.push(args) + return { + outbox: outboxEntries, + blockedClientMessageId: null, + error: null, + send: vi.fn(), + retry: vi.fn(), + withdrawUnsent: vi.fn() + } + } +})) + +import { + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { ConversationCommandParams } from '../../../../shared/rpc-contract/structured-agent-session-params' +import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' +import { useStructuredAgentSession } from './use-structured-agent-session' + +const RUNNING_TURN: AgentJournalRenderItem = { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } +} + +function draft(id: string): AgentSessionQueuedMessage { + return { + messageId: id, + position: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `queued ${id}` }] }, + state: 'waiting' + } +} + +function render(queueFollowUps?: boolean) { + return renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'claude', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1', + ...(queueFollowUps === undefined ? {} : { queueFollowUps }) + }) + ) +} + +function cancels(): unknown[] { + return mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.cancel') + .map(([, , params]) => params) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.outboxArgs.length = 0 + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.cancel' + ? { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'e', sequence: 1 }, + value: { cancelled: true } + } + : null + ) + items = [RUNNING_TURN] + queuedMessages = undefined + outboxEntries = [] + localStorage.clear() + clearNativeChatDraftCacheForTests() +}) + +afterEach(() => { + setLocalRuntimeCapabilitiesForTests(null) +}) + +describe('against a capable host', () => { + beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ]) + }) + + it('queues sends while the setting is on, immediately when it is off', () => { + render() + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: true + }) + mocks.outboxArgs.length = 0 + render(false) + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: false + }) + }) + + it('Stop is a plain cancel: drafts stay as cards and no text lands in the composer', async () => { + queuedMessages = [{ ...draft('draft-1'), paused: true }] + const { result } = render(false) + await act(async () => { + await result.current.stop() + }) + const [params] = cancels() + expect(params).toBeDefined() + expect(params).not.toHaveProperty('withdrawQueued') + // The host still owns the draft; the client shows it paused and restores nothing. + expect(result.current.queuedMessages.cards).toMatchObject([ + { messageId: 'draft-1', hold: 'paused' } + ]) + expect(readNativeChatDraftCache('scope-1')).toBe('') + }) + + it("/clear is exactly today's command — drafts are the host's to carry", async () => { + items = [] + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.conversationCommand' + ? { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'e', sequence: 1 }, + value: { command: 'clear', state: 'completed' } + } + : null + ) + const { result } = render() + await act(async () => { + await result.current.runConversationCommand('clear') + }) + const clearCall = mocks.call.mock.calls.find( + ([, method]) => method === 'agentSession.conversationCommand' + ) + // The host's REAL strict schema accepts the request as sent — and it carries + // no withdraw key: the host moves the drafts to the replacement session itself. + const parsed = ConversationCommandParams.parse(clearCall?.[2]) + expect(parsed.command).toBe('clear') + expect('withdrawQueued' in parsed).toBe(false) + // Fingerprint parity with the host's digest; a mismatch would refuse the + // operation at admission. + expect(parsed.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: 'session-1', + fields: { command: parsed.command } + }) + ) + }) + + it("an unconfirmed /clear's next press replays its operation id (the host refuses any other)", async () => { + items = [] + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.conversationCommand' + ? { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'e', sequence: 1 }, + value: { command: 'clear', state: 'unknown' } + } + : null + ) + const { result } = render() + await act(async () => { + await result.current.runConversationCommand('clear') + }) + await act(async () => { + await result.current.runConversationCommand('clear') + }) + const ids = mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.conversationCommand') + .map(([, , params]) => ConversationCommandParams.parse(params).envelope.clientOperationId) + expect(ids).toHaveLength(2) + expect(ids[1]).toBe(ids[0]) + }) + + it('a mid-turn queue send is never a transcript bubble, before or after the host holds it', () => { + const entry = (id: string, text: string) => + createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: 'session-1', + text, + attachments: [], + queuedAt: 1 + }) + outboxEntries = [ + // Not sent yet: against a host that queues, with the setting on, it will ask to be queued. + entry('pending-queue', 'awaiting the answer'), + // Sent plain: a bubble, whatever the capability now says. + { + ...entry('plain', 'immediate send'), + state: 'dispatching', + lastAttemptAt: 2, + sentDelivery: null + } + ] + const working = render() + const workingText = JSON.stringify(working.result.current.messages) + expect(workingText).not.toContain('awaiting the answer') + expect(workingText).toContain('immediate send') + // The host already publishes it as a draft: the card alone shows it, whatever the turn. + items = [] + queuedMessages = [draft('pending-queue')] + const idle = render() + expect(JSON.stringify(idle.result.current.messages)).not.toContain('awaiting the answer') + queuedMessages = undefined + const idleUnheld = render() + expect(JSON.stringify(idleUnheld.result.current.messages)).toContain('awaiting the answer') + }) + + it('shows host-held drafts as cards, never as transcript bubbles', () => { + queuedMessages = [draft('draft-1')] + const { result } = render() + expect(result.current.queuedMessages.cards.map((card) => card.text)).toEqual(['queued draft-1']) + const transcriptText = JSON.stringify(result.current.messages) + expect(transcriptText).not.toContain('queued draft-1') + }) +}) + +describe('against a host without the capability', () => { + beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) + }) + + it('never asks for queue delivery, whatever the setting says', () => { + render() + expect(mocks.outboxArgs.at(-1)?.queueDelivery?.capability).toBe('unsupported') + }) + + it("Stop stays exactly today's conversation Stop — no withdrawQueued key at all", async () => { + const { result } = render() + await act(async () => { + await result.current.stop() + }) + const [params] = cancels() + expect(params).toBeDefined() + expect(params).not.toHaveProperty('withdrawQueued') + }) + + it("/clear stays exactly today's command — no withdrawQueued key", async () => { + items = [] + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.conversationCommand' + ? { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'e', sequence: 1 }, + value: { command: 'clear', state: 'completed' } + } + : null + ) + const { result } = render() + await act(async () => { + await result.current.runConversationCommand('clear') + }) + const clearCall = mocks.call.mock.calls.find( + ([, method]) => method === 'agentSession.conversationCommand' + ) + const parsed = ConversationCommandParams.parse(clearCall?.[2]) + expect('withdrawQueued' in parsed).toBe(false) + expect(parsed.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: 'session-1', + fields: { command: parsed.command } + }) + ) + }) + + it('steering the newest card is inert', () => { + queuedMessages = [draft('draft-1')] + const { result } = render() + expect(result.current.queuedMessages.steerNewest()).toBe(false) + expect( + mocks.call.mock.calls.filter(([, method]) => + String(method).startsWith('agentSession.queuedMessage') + ) + ).toHaveLength(0) + }) +}) 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 ce66d47cbce9..e803c6557c12 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 @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import * as structuredConversationCommands from './structured-conversation-command-send' import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' @@ -12,7 +12,10 @@ import { supportsStructuredAgentSessionPromptCancel, supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client' -import { useStructuredAgentSessionHostStopsConversation } from '@/runtime/structured-agent-session-host-capability' +import { + useStructuredAgentSessionHostQueuesMessagesState, + useStructuredAgentSessionHostStopsConversation +} from '@/runtime/structured-agent-session-host-capability' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import { legacyAgentSessionSelectedOptionId, @@ -30,6 +33,8 @@ import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisi import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal' 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 { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -46,8 +51,10 @@ export function useStructuredAgentSession(args: { providerStarting?: boolean /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView - /** The composer that gets back what a Stop withdrew. */ + /** The composer Edit copies a card's text into, and that gets back unsent outbox text. */ composerScopeKey?: string + /** The chat-wide "queue follow-ups" setting; off keeps mid-turn sends immediate. */ + queueFollowUps?: boolean }) { const { agent, @@ -55,6 +62,7 @@ export function useStructuredAgentSession(args: { isVisible, launch, providerStarting = false, + queueFollowUps = true, sessionId, target, transportEnabled = true @@ -89,12 +97,22 @@ export function useStructuredAgentSession(args: { mutate, ...(launch ? { launch } : {}) }) + // Only a capable host may see `delivery` or the queuedMessage RPCs; against + // anything older this client must look exactly like today's. + const queueCapability = useStructuredAgentSessionHostQueuesMessagesState(target) + const queueCapable = queueCapability === 'supported' + const queuedMessageIds = useMemo( + () => (transportState.queuedMessages ?? []).map((message) => message.messageId), + [transportState.queuedMessages] + ) const outboxController = useStructuredAgentSessionOutbox({ sessionId, target, fence: transportState.fence, submissions: transportState.submissions, - composerScopeKey + composerScopeKey, + queueDelivery: { capability: queueCapability, enabled: queueFollowUps }, + queuedMessageIds }) const threadGoal = useStructuredAgentSessionThreadGoal({ @@ -129,11 +147,40 @@ export function useStructuredAgentSession(args: { transportState.submissions, outboxController.blockedClientMessageId ))) + // A queued send is a card, never a transcript bubble. + const isWorking = transportState.isWorking + const transcriptOutbox = useMemo( + () => + outboxOutsideQueuedCards( + outbox, + queuedMessageIds, + isWorking, + outboxController.blockedClientMessageId, + { capability: queueCapability, enabled: queueFollowUps } + ), + [ + isWorking, + outbox, + outboxController.blockedClientMessageId, + queueCapability, + queueFollowUps, + queuedMessageIds + ] + ) const messages = useStructuredAgentSessionMessages( transportState.journalItems, - outbox, + transcriptOutbox, transportState.submissions ) + const queuedController = useStructuredAgentSessionQueuedMessages({ + enabled: queueCapable && transportState.fence !== null, + queuedMessages: transportState.queuedMessages, + queuePause: transportState.queuePause, + submissions: transportState.submissions, + hasPendingPrompt: prompts.length > 0, + composerScopeKey, + mutate + }) return { conversationCommands, runConversationCommand: (command: AgentSessionConversationCommand) => @@ -185,6 +232,9 @@ export function useStructuredAgentSession(args: { canStop, stop: () => { if (stopsConversation) { + // Unsent text this client still owns goes back to its composer — a local move. + // Host-held drafts are never withdrawn by a Stop: the host pauses them and + // they stay visible as cards, on every device, until the user acts on one. outboxController.withdrawUnsent() return mutate('agentSession.cancel', 'agentSession.cancel', {}) } @@ -192,6 +242,7 @@ export function useStructuredAgentSession(args: { ? mutate('agentSession.cancel', 'agentSession.cancel', { turnId: transportState.turnId }) : Promise.resolve(null) }, + queuedMessages: queuedController, cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { // Capability negotiation must complete before mutate constructs the payload // fingerprint and operation id: older hosts reject the strict prompt field. diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx index f33e7f02cf1c..d876e33f00c2 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.test.tsx @@ -1,12 +1,18 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { NativeChatExperimentalSetting } from './NativeChatExperimentalSetting' -afterEach(() => cleanup()) +afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + vi.unstubAllGlobals() +}) const SHELL_ENV_TOGGLE = '[aria-label="Toggle using your shell environment"]' const NAME_INPUT = '#settings-native-chat-shell-environment-name' @@ -205,3 +211,50 @@ describe('NativeChatExperimentalSetting shell environment', () => { expect(nameInput(container).value).toBe('HTTPS_PRO') }) }) + +describe('NativeChatExperimentalSetting queue follow-ups', () => { + const QUEUE_TOGGLE = '[aria-label="Toggle queue follow-ups"]' + const structuredOn = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true + } + + it('shows the switch, with copy naming the image exception, when the host queues messages', () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) + const updateSettings = vi.fn() + const { container } = renderSetting(structuredOn, updateSettings) + expect(container.textContent).toContain('Messages with images send right away.') + fireEvent.click(container.querySelector(QUEUE_TOGGLE)!) + expect(updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) + }) + + it('hides the switch when the host does not queue messages, since it would do nothing', () => { + setLocalRuntimeCapabilitiesForTests([]) + const { container } = renderSetting(structuredOn) + expect(container.querySelector(QUEUE_TOGGLE)).toBeNull() + }) + + it('hides the switch until the host answers, then shows it', async () => { + setLocalRuntimeCapabilitiesForTests(null) + let answer: (status: { capabilities: string[] }) => void = () => {} + vi.stubGlobal('api', { + runtime: { getStatus: () => new Promise((resolve) => (answer = resolve)) } + }) + const { container } = renderSetting(structuredOn) + expect(container.querySelector(QUEUE_TOGGLE)).toBeNull() + await act(async () => + answer({ capabilities: [AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY] }) + ) + expect(container.querySelector(QUEUE_TOGGLE)).not.toBeNull() + }) + + it('stays hidden outside structured chat even when the host queues messages', () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) + const { container } = renderSetting({ + ...structuredOn, + experimentalStructuredNativeChat: false + }) + expect(container.querySelector(QUEUE_TOGGLE)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index f4109d6df851..d7f1d0e29138 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -2,6 +2,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { NativeChatQueueFollowUpsSetting } from './NativeChatQueueFollowUpsSetting' import { NativeChatShellEnvironmentSetting } from './NativeChatShellEnvironmentSetting' import { NativeChatSupportedAgents } from './NativeChatSupportedAgents' import { SearchableSetting } from './SearchableSetting' @@ -185,6 +186,10 @@ export function NativeChatExperimentalSetting({ ) : null} + {structuredChatActive ? ( + + ) : null} + {structuredChatActive ? ( ) => void +} + +export function NativeChatQueueFollowUpsSetting({ + settings, + updateSettings +}: NativeChatQueueFollowUpsSettingProps): React.JSX.Element | null { + const hostQueuesMessages = useStructuredAgentSessionHostQueuesMessages(STRUCTURED_CHAT_HOST) + // A host without the capability ignores the preference; the switch would do nothing. + if (!hostQueuesMessages) { + return null + } + const queueFollowUpsEnabled = settings.nativeChatQueueFollowUps !== false + return ( +
    +
    + +

    + {translate( + 'components.settings.nativeChat.queueFollowUpsCopy', + 'Messages you send while the agent is working wait as cards you can steer, edit, or delete. Messages with images send right away.' + )} +

    +
    + updateSettings({ nativeChatQueueFollowUps: !queueFollowUpsEnabled })} + /> +
    + ) +} diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts index 502c407056a3..1bedfdb164bb 100644 --- a/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts +++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts @@ -6,4 +6,8 @@ describe('native chat experimental search entry', () => { it.each(['openclaude', 'omp'])('matches the supported-agent keyword %s', (query) => { expect(matchesSettingsSearch(query, getNativeChatExperimentalSearchEntry())).toBe(true) }) + + it('matches "queue" so the Queue follow-ups switch is findable', () => { + expect(matchesSettingsSearch('queue', getNativeChatExperimentalSearchEntry())).toBe(true) + }) }) diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts index 87427d81e8af..39a09056b7fc 100644 --- a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts +++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts @@ -49,6 +49,10 @@ export function getNativeChatExperimentalSearchEntry(): SettingsSearchEntry { ...translateSearchKeyword( 'auto.components.settings.experimental.search.nativeChat.agent', 'agent' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.nativeChat.queue', + 'queue' ) ] } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91f2275b0e2e..2a64e22e318c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9729,7 +9729,8 @@ "openclaude": "openclaude", "omp": "omp", "terminal": "terminal", - "agent": "agent" + "agent": "agent", + "queue": "queue" }, "agentDashboard": { "title": "Agent Dashboard", @@ -17887,6 +17888,29 @@ "label": "Context {{used}} of {{window}} tokens, {{percent}}% used", "title": "Context", "estimated": "Estimated from the last response." + }, + "queuedMessages": { + "behindReturnedHold": "Waiting — a message ahead needs attention", + "awaitingAnswerHold": "Waiting for your answer", + "steer": "Steer", + "send": "Send", + "steerHint": "Submit without interrupting the model", + "sendHint": "Send this message now", + "delete": "Delete", + "moreActions": "More actions", + "editMessage": "Edit message", + "turnOffQueueing": "Turn off queueing", + "listLabel": "Queued messages", + "alreadySent": "This message was already sent.", + "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" } }, "tab": { @@ -18209,6 +18233,11 @@ "spaces": "spaces" } } + }, + "nativeChat": { + "queueFollowUpsTitle": "Queue follow-ups", + "queueFollowUpsCopy": "Messages you send while the agent is working wait as cards you can steer, edit, or delete. Messages with images send right away.", + "queueFollowUpsToggleLabel": "Toggle queue follow-ups" } }, "agentChildRow": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3aff95b8d5e0..cf22bb140103 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8379,7 +8379,8 @@ "nativeChat": { "title": "Chat UI", "description": "Previsualiza la interfaz de chat de escritorio para sesiones de terminal de agentes compatibles.", - "grok": "grok" + "grok": "grok", + "queue": "cola" }, "agentDashboard": { "title": "Panel de agentes", @@ -15119,6 +15120,29 @@ "subagents": { "unnamed": "Subagente", "writtenBy": "Escrito por el subagente {{value0}}" + }, + "queuedMessages": { + "alreadySent": "Este mensaje ya se envió.", + "editAlreadySent": "Ya se envió — tu texto sigue en el cuadro de mensaje.", + "listLabel": "Mensajes en cola", + "behindReturnedHold": "En espera: un mensaje anterior necesita atención", + "awaitingAnswerHold": "Esperando tu respuesta", + "steer": "Enviar ahora", + "send": "Enviar", + "steerHint": "Enviar sin interrumpir al modelo", + "sendHint": "Enviar este mensaje ahora", + "delete": "Eliminar", + "moreActions": "Más acciones", + "editMessage": "Editar mensaje", + "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" } }, "tab": { @@ -15186,6 +15210,13 @@ }, "agentChildRow": { "ended": "Finalizado" + }, + "settings": { + "nativeChat": { + "queueFollowUpsTitle": "Poner mensajes en cola", + "queueFollowUpsCopy": "Los mensajes que envías mientras el agente trabaja esperan como tarjetas que puedes enviar ahora, editar o eliminar. Los mensajes con imágenes se envían de inmediato.", + "queueFollowUpsToggleLabel": "Alternar la cola de mensajes" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 42058065579b..608c3e40e5f7 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -9660,7 +9660,8 @@ "openclaude": "openclaude", "omp": "omp", "terminal": "Terminal", - "agent": "agent" + "agent": "agent", + "queue": "file d'attente" }, "agentDashboard": { "title": "Tableau de bord des agents", @@ -17819,6 +17820,29 @@ "label": "Contexte : {{used}} tokens sur {{window}}, {{percent}} % utilisés", "title": "Contexte", "estimated": "Estimation basée sur la dernière réponse." + }, + "queuedMessages": { + "alreadySent": "Ce message a déjà été envoyé.", + "editAlreadySent": "Déjà envoyé — votre texte reste dans la zone de saisie.", + "listLabel": "Messages en file d'attente", + "behindReturnedHold": "En attente — un message précédent nécessite votre attention", + "awaitingAnswerHold": "En attente de votre réponse", + "steer": "Envoyer maintenant", + "send": "Envoyer", + "steerHint": "Envoyer sans interrompre le modèle", + "sendHint": "Envoyer ce message maintenant", + "delete": "Supprimer", + "moreActions": "Plus d'actions", + "editMessage": "Modifier le message", + "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" } }, "tab": { @@ -18181,6 +18205,11 @@ "spaces": "espaces" } } + }, + "nativeChat": { + "queueFollowUpsTitle": "Mettre les messages en file d’attente", + "queueFollowUpsCopy": "Les messages envoyés pendant que l'agent travaille attendent sous forme de cartes que vous pouvez envoyer maintenant, modifier ou supprimer. Les messages avec des images sont envoyés immédiatement.", + "queueFollowUpsToggleLabel": "Activer ou désactiver la file d'attente" } }, "agentChildRow": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cc86474fe8b8..d95627763059 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9537,7 +9537,8 @@ "openclaude": "オープンクロード", "omp": "ああ", "terminal": "ターミナル", - "agent": "Agent" + "agent": "Agent", + "queue": "キュー" }, "agentDashboard": { "title": "Agent ダッシュボード", @@ -17755,6 +17756,29 @@ "label": "コンテキスト {{used}} / {{window}} トークン、{{percent}}% 使用済み", "title": "コンテキスト", "estimated": "最後の応答から推定。" + }, + "queuedMessages": { + "alreadySent": "このメッセージはすでに送信されています。", + "editAlreadySent": "すでに送信されています — テキストは入力欄に残っています。", + "listLabel": "キュー内のメッセージ", + "behindReturnedHold": "待機中 — 前のメッセージの対応が必要です", + "awaitingAnswerHold": "回答を待っています", + "steer": "今すぐ送信", + "send": "送信", + "steerHint": "モデルを中断せずに送信します", + "sendHint": "このメッセージを今すぐ送信します", + "delete": "削除", + "moreActions": "その他の操作", + "editMessage": "メッセージを編集", + "turnOffQueueing": "キューへの追加をオフにする", + "withdrawnHold": "送信前に停止されました", + "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", + "paused": "一時停止中", + "queuePausedStopped": "中断したため、キューを一時停止しました", + "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", + "queuePausedCleared": "会話を消去したため、キューを一時停止しました", + "queuePaused": "キューは一時停止中です", + "resume": "再開" } }, "tab": { @@ -18082,6 +18106,11 @@ "spaces": "スペース" } } + }, + "nativeChat": { + "queueFollowUpsTitle": "フォローアップをキューに入れる", + "queueFollowUpsCopy": "Agent の作業中に送信したメッセージは、今すぐ送信・編集・削除できるカードとして待機します。画像付きのメッセージはすぐに送信されます。", + "queueFollowUpsToggleLabel": "フォローアップのキューを切り替え" } }, "agentChildRow": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aa8bf3912d13..abf4c7c85b8e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9500,7 +9500,8 @@ "openclaude": "개방형", "omp": "펌프", "terminal": "터미널", - "agent": "에이전트" + "agent": "에이전트", + "queue": "대기열" }, "agentDashboard": { "title": "에이전트 대시보드", @@ -17755,6 +17756,29 @@ "label": "컨텍스트 {{used}}/{{window}} 토큰, {{percent}}% 사용", "title": "컨텍스트", "estimated": "마지막 응답 기준 추정치입니다." + }, + "queuedMessages": { + "alreadySent": "이 메시지는 이미 전송되었습니다.", + "editAlreadySent": "이미 전송되었습니다 — 텍스트는 입력창에 남아 있습니다.", + "listLabel": "대기 중인 메시지", + "behindReturnedHold": "대기 중 — 앞의 메시지에 확인이 필요합니다", + "awaitingAnswerHold": "답변을 기다리는 중", + "steer": "지금 보내기", + "send": "보내기", + "steerHint": "모델을 중단하지 않고 보냅니다", + "sendHint": "이 메시지를 지금 보냅니다", + "delete": "삭제", + "moreActions": "추가 작업", + "editMessage": "메시지 편집", + "turnOffQueueing": "대기열 사용 안 함", + "withdrawnHold": "전송 전에 중지됨", + "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", + "paused": "일시 중지됨", + "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", + "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", + "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", + "queuePaused": "대기열이 일시 중지되었습니다", + "resume": "재개" } }, "tab": { @@ -18082,6 +18106,11 @@ "spaces": "공백" } } + }, + "nativeChat": { + "queueFollowUpsTitle": "후속 메시지 대기열에 추가", + "queueFollowUpsCopy": "Agent가 작업하는 동안 보낸 메시지는 지금 보내기, 편집, 삭제할 수 있는 카드로 대기합니다. 이미지가 포함된 메시지는 바로 전송됩니다.", + "queueFollowUpsToggleLabel": "후속 메시지 대기열 전환" } }, "agentChildRow": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e61395b42918..6e6d14798235 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9500,7 +9500,8 @@ "openclaude": "开放克劳德", "omp": "奥普", "terminal": "终端", - "agent": "代理" + "agent": "代理", + "queue": "队列" }, "agentDashboard": { "title": "智能体仪表盘", @@ -17720,6 +17721,29 @@ "label": "上下文 {{used}}/{{window}} token,已用 {{percent}}%", "title": "上下文", "estimated": "根据上一次响应的估算值。" + }, + "queuedMessages": { + "alreadySent": "此消息已发送。", + "editAlreadySent": "已发送 — 你的文本仍在输入框中。", + "listLabel": "排队的消息", + "behindReturnedHold": "等待中 — 前面有一条消息需要处理", + "awaitingAnswerHold": "等待你的回答", + "steer": "立即发送", + "send": "发送", + "steerHint": "提交且不中断模型", + "sendHint": "立即发送此消息", + "delete": "删除", + "moreActions": "更多操作", + "editMessage": "编辑消息", + "turnOffQueueing": "关闭排队", + "withdrawnHold": "在发送前已停止", + "pausedSendFailed": "无法发送。点按发送以重试。", + "paused": "已暂停", + "queuePausedStopped": "因你中断了操作,队列已暂停", + "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", + "queuePausedCleared": "清除对话后,队列已暂停", + "queuePaused": "队列已暂停", + "resume": "继续" } }, "tab": { @@ -18082,6 +18106,11 @@ "spaces": "空间" } } + }, + "nativeChat": { + "queueFollowUpsTitle": "将后续消息加入队列", + "queueFollowUpsCopy": "Agent 工作时发送的消息会以卡片形式等待,你可以立即发送、编辑或删除。带图片的消息会立即发送。", + "queueFollowUpsToggleLabel": "切换后续消息队列" } }, "agentChildRow": { diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx b/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx index 121b0719d3d4..22ceb0b49793 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx @@ -1,20 +1,34 @@ // @vitest-environment happy-dom -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' -const mocks = vi.hoisted(() => ({ supports: vi.fn() })) +const mocks = vi.hoisted(() => ({ + supports: vi.fn(), + contactRegained: new Map void>() +})) vi.mock('./runtime-rpc-client', () => ({ runtimeEnvironmentSupportsCapability: mocks.supports })) +vi.mock('./runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (environmentId: string, listener: () => void) => { + mocks.contactRegained.set(environmentId, listener) + return () => mocks.contactRegained.delete(environmentId) + } +})) + import { setLocalRuntimeCapabilitiesForTests } from './local-runtime-capabilities' -import { useStructuredAgentSessionHostStopsConversation } from './structured-agent-session-host-capability' +import { + useStructuredAgentSessionHostQueuesMessagesState, + useStructuredAgentSessionHostStopsConversation +} from './structured-agent-session-host-capability' afterEach(() => { setLocalRuntimeCapabilitiesForTests(null) @@ -51,3 +65,35 @@ describe('whether a host takes a Stop naming no turn', () => { ) }) }) + +describe('the queued-messages capability, three-state', () => { + it('reads a failed remote probe as unknown, and asks again once contact is regained', async () => { + mocks.supports.mockRejectedValueOnce(new Error('unreachable')) + const { result } = renderHook(() => + useStructuredAgentSessionHostQueuesMessagesState({ + kind: 'environment', + environmentId: 'env-1' + }) + ) + await waitFor(() => expect(mocks.supports).toHaveBeenCalledTimes(1)) + expect(result.current).toBe('unknown') + + mocks.supports.mockResolvedValueOnce(true) + act(() => { + mocks.contactRegained.get('env-1')?.() + }) + await waitFor(() => expect(result.current).toBe('supported')) + expect(mocks.supports).toHaveBeenLastCalledWith( + 'env-1', + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ) + }) + + it('reads a local runtime that has not answered as unknown', () => { + setLocalRuntimeCapabilitiesForTests(null) + const { result } = renderHook(() => + useStructuredAgentSessionHostQueuesMessagesState({ kind: 'local' }) + ) + expect(result.current).toBe('unknown') + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index 22e59365bf1c..2b1a76d2c5b4 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' import type { RuntimeClientTarget } from './runtime-client-target' @@ -9,47 +10,77 @@ import { readLocalRuntimeCapabilitiesOrUnknown } from './local-runtime-capabilities' import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' +import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained' export function structuredAgentSessionHostKey(target: RuntimeClientTarget): string { return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } -/** Whether the session's host advertises `capability`. False until the host has said so, and for - * a failed probe: an older host is handled as it always was. */ -export function useStructuredAgentSessionHostCapability( +/** `unknown` until the host has answered, and after a failed probe: a failed probe is not + * evidence about the host. */ +export type StructuredAgentSessionHostCapabilityState = 'unknown' | 'supported' | 'unsupported' + +/** Whether the session's host advertises `capability`. A failed probe stays `unknown` and a remote + * host is asked again each time this client regains contact with it. */ +export function useStructuredAgentSessionHostCapabilityState( target: RuntimeClientTarget, capability: RuntimeCapability -): boolean { +): StructuredAgentSessionHostCapabilityState { const key = structuredAgentSessionHostKey(target) - const [answer, setAnswer] = useState<{ key: string; supported: boolean }>(() => ({ - key, - supported: - target.kind === 'local' && - (readLocalRuntimeCapabilitiesOrUnknown()?.includes(capability) ?? false) - })) + const [answer, setAnswer] = useState<{ + key: string + state: StructuredAgentSessionHostCapabilityState + }>(() => ({ key, state: target.kind === 'local' ? localCapabilityState(capability) : 'unknown' })) const environmentId = target.kind === 'environment' ? target.environmentId : null + const [contact, setContact] = useState(0) + useEffect(() => { + if (environmentId === null) { + return + } + return subscribeRuntimeHostContactRegained(environmentId, () => setContact((n) => n + 1)) + }, [environmentId]) useEffect(() => { let cancelled = false - const probe = + const probe: Promise = environmentId === null - ? ensureLocalRuntimeCapabilities().then( - (capabilities) => capabilities?.includes(capability) ?? false + ? ensureLocalRuntimeCapabilities().then(() => localCapabilityState(capability)) + : runtimeEnvironmentSupportsCapability(environmentId, capability).then((supported) => + supported ? 'supported' : 'unsupported' ) - : runtimeEnvironmentSupportsCapability(environmentId, capability) void probe - .catch(() => false) - .then((supported) => { + .catch((): StructuredAgentSessionHostCapabilityState => 'unknown') + .then((state) => { if (!cancelled) { setAnswer((current) => - current.key === key && current.supported === supported ? current : { key, supported } + current.key === key && current.state === state ? current : { key, state } ) } }) return () => { cancelled = true } - }, [capability, environmentId, key]) - return answer.key === key && answer.supported + }, [capability, contact, environmentId, key]) + return answer.key === key ? answer.state : 'unknown' +} + +function localCapabilityState( + capability: RuntimeCapability +): StructuredAgentSessionHostCapabilityState { + const capabilities = readLocalRuntimeCapabilitiesOrUnknown() + return capabilities === null + ? 'unknown' + : capabilities.includes(capability) + ? 'supported' + : 'unsupported' +} + +/** Whether the session's host advertises `capability`. False until the host has said so, and for + * a failed probe: an older host is handled as it always was. */ +export function useStructuredAgentSessionHostCapability( + target: RuntimeClientTarget, + capability: RuntimeCapability +): boolean { + return useStructuredAgentSessionHostCapabilityState(target, capability) === 'supported' } /** Whether the host takes a Stop naming no turn: the only Stop before the provider opens one. */ @@ -61,3 +92,20 @@ export function useStructuredAgentSessionHostStopsConversation( AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY ) } + +/** Whether the host holds mid-turn sends as drafts: only then may a client send `delivery` + * or call the queuedMessage RPCs. */ +export function useStructuredAgentSessionHostQueuesMessages(target: RuntimeClientTarget): boolean { + return useStructuredAgentSessionHostQueuesMessagesState(target) === 'supported' +} + +/** Three-state, for the outbox: only `supported` lets a first attempt ask to be queued, and only + * `unsupported` drops the field from a replay; `unknown` holds nothing back. */ +export function useStructuredAgentSessionHostQueuesMessagesState( + target: RuntimeClientTarget +): StructuredAgentSessionHostCapabilityState { + return useStructuredAgentSessionHostCapabilityState( + target, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ) +} diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 33deed6de9ce..f429a384fde7 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -136,6 +136,7 @@ export function buildDefaultSettings(args: { experimentalNativeChat: false, experimentalStructuredNativeChat: false, nativeChatResumeWorkOnRestart: false, + nativeChatQueueFollowUps: true, nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [], nativeChatSessionOptions: {}, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index eedb140c3adb..10b062ecbcd0 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -233,6 +233,9 @@ export type GlobalSettings = { /** Opt-in: resume working structured chats automatically on the next launch. Off still offers * the list, so the user sees exactly what would run before anything spends tokens. */ nativeChatResumeWorkOnRestart?: boolean + /** Chat-wide: hold a mid-turn send as an editable queued draft that goes when the turn ends + * (capable hosts only). Absent = on; off keeps mid-turn sends immediate. */ + nativeChatQueueFollowUps?: boolean /** Structured chat only: Codex/Claude children inherit the whole login-shell environment. * Off passes only `nativeChatShellEnvironmentVariables` (plus a PATH/locale baseline). */ nativeChatInheritShellEnvironment?: boolean diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index f5b9dbdec86a..9d34b8db3825 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -68,4 +68,61 @@ describe('structured agent session event coalescer', () => { expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ activity: null }) }) + + it('preserves a queued-message list when later coalesced frames omit it', () => { + const events: AgentSessionSubscribeEvent[] = [] + const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) + const list = [ + { + messageId: 'draft-1', + position: 1, + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + state: 'waiting' as const + } + ] + + coalescer.push({ ...batch(1), queuedMessages: list }) + coalescer.push(batch(2)) + coalescer.flush() + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ queuedMessages: list }) + }) + + it('keeps the latest queued-message list, an emptied one included', () => { + const events: AgentSessionSubscribeEvent[] = [] + const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) + + coalescer.push({ + ...batch(1), + queuedMessages: [ + { + messageId: 'draft-1', + position: 1, + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + state: 'waiting' as const + } + ] + }) + coalescer.push({ ...batch(2), queuedMessages: [] }) + coalescer.flush() + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ queuedMessages: [] }) + }) + + it('keeps the queue pause with the list it was published with', () => { + const events: AgentSessionSubscribeEvent[] = [] + const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) + + coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'restarted' } }) + coalescer.push(batch(2)) + coalescer.flush() + expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'restarted' } }) + + coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) + coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) + coalescer.flush() + expect(events[1]).toMatchObject({ queuePause: { reason: 'stopped' } }) + }) }) diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index 17de32fd71b8..8564056ce048 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -28,6 +28,13 @@ function mergeBatch( ...(right.commands !== undefined || left.commands !== undefined ? { commands: right.commands !== undefined ? right.commands : left.commands } : {}), + // Whole-list publication, latest wins: dropping it here would lose a draft + // update that rode a coalesced token frame. The pause rides with its list. + ...(right.queuedMessages !== undefined + ? { queuedMessages: right.queuedMessages, queuePause: right.queuePause ?? null } + : left.queuedMessages !== undefined + ? { queuedMessages: left.queuedMessages, queuePause: left.queuePause ?? null } + : {}), sessionId: right.sessionId, batch: { cursor: right.batch.cursor, diff --git a/src/shared/structured-agent-session-draft-hand-off.test.ts b/src/shared/structured-agent-session-draft-hand-off.test.ts new file mode 100644 index 000000000000..4a493d9e6223 --- /dev/null +++ b/src/shared/structured-agent-session-draft-hand-off.test.ts @@ -0,0 +1,75 @@ +// The link from a submission to the queued draft it hands off (`queuedMessageId`) decides every +// question about who owns a queued send; a draft id is never compared with a submission id. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' +import { + disposeStructuredAgentSessionSendResult, + journalAnswersInFlightSend +} from './structured-agent-session-send-disposition' +import { hasUnsentStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-stop-withdrawal' + +const entry: StructuredAgentSessionOutboxEntry = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'draft', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 + }), + sentDelivery: 'queue-if-active', + state: 'unconfirmed', + lastAttemptAt: 5 +} + +function handOff(dispatchState: AgentJournalSubmission['dispatchState']): AgentJournalSubmission { + return { + clientMessageId: 'hand-off', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState, + providerItemId: null, + reason: dispatchState === 'rejected' ? 'refused' : null, + submittedAt: 10, + resolvedAt: null, + queuedMessageId: 'draft' + } +} + +describe('a queued draft handed off under a fresh submission id', () => { + it('takes the outbox entry off the client, in every dispatch state', () => { + for (const state of ['pending', 'accepted', 'rejected', 'unknown'] as const) { + expect(reconcileStructuredAgentSessionOutboxWithQueue([entry], [handOff(state)])).toEqual([]) + } + }) + + it('answers the send in flight and leaves nothing a Stop would withdraw', () => { + expect(journalAnswersInFlightSend([handOff('pending')], 'draft')).toBe(true) + expect(journalAnswersInFlightSend([handOff('pending')], null)).toBe(false) + expect(hasUnsentStructuredAgentSessionOutboxEntry([entry], [handOff('pending')], null)).toBe( + false + ) + }) + + it('settles a replayed send answered with the hand-off, with no notice', () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + createOperationId: () => 'rotated', + result: { + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { clientMessageId: 'hand-off', submission: handOff('rejected') } + } + }) + expect(disposition).toEqual({ entries: [], error: null, blockedClientMessageId: null }) + }) +}) diff --git a/src/shared/structured-agent-session-draft-hand-off.ts b/src/shared/structured-agent-session-draft-hand-off.ts new file mode 100644 index 000000000000..29a141590381 --- /dev/null +++ b/src/shared/structured-agent-session-draft-hand-off.ts @@ -0,0 +1,41 @@ +// A submission's `queuedMessageId` names the queued draft it hands off. The host sends every draft +// under a fresh submission id, so this link, never a draft id compared with a `clientMessageId`, +// is how a client knows the host has taken a message over. + +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +/** Ids of the queued drafts the journal shows handed off, in any dispatch state. An outbox entry + * under one of these ids belongs to the host: its card or bubble carries the text from here. */ +export function handedOffQueuedMessageIds( + submissions: readonly AgentJournalSubmission[] +): Set { + const ids = new Set() + for (const submission of submissions) { + if (submission.queuedMessageId !== undefined) { + ids.add(submission.queuedMessageId) + } + } + return ids +} + +/** + * `reconcileStructuredAgentSessionOutbox` with the queue's rule first: an entry the host handed off + * as a queued draft belongs to the host, whatever that hand-off's state, so it leaves with no + * restore and no Retry row. Every reading of the outbox against the journal goes through this. + */ +export function reconcileStructuredAgentSessionOutboxWithQueue( + entries: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): StructuredAgentSessionOutboxEntry[] { + const handedOff = handedOffQueuedMessageIds(submissions) + return reconcileStructuredAgentSessionOutbox( + handedOff.size === 0 + ? entries + : entries.filter((entry) => !handedOff.has(entry.clientMessageId)), + submissions + ) +} diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index da8017a2d504..98a5921ae1de 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -3,10 +3,8 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import type { NativeChatMessage } from './native-chat-types' -import { - reconcileStructuredAgentSessionOutbox, - type StructuredAgentSessionOutboxEntry -} from './structured-agent-session-outbox' +import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' export function projectStructuredAgentSessionMessages( @@ -15,7 +13,7 @@ export function projectStructuredAgentSessionMessages( submissions: readonly AgentJournalSubmission[], projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { - const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions) + const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. const rejected = new Set( submissions diff --git a/src/shared/structured-agent-session-operation-identity.ts b/src/shared/structured-agent-session-operation-identity.ts index bae28095695f..519c917bcafd 100644 --- a/src/shared/structured-agent-session-operation-identity.ts +++ b/src/shared/structured-agent-session-operation-identity.ts @@ -1,13 +1,16 @@ /** - * Whether a structured-session cancel names what it stops (a turn, a prompt, a task), so another - * cancel with the same payload is the same intent and may reuse its operation id to replay. A Stop - * naming no turn, or one stopping every background task, acts on whatever is in flight when the - * host reaches it: its payload is shared with every later one, so each press gets its own id. + * Whether a structured-session write names what it acts on (a turn, a prompt, a task, a draft), so + * another with the same payload is the same intent and may reuse its operation id to replay. A Stop + * naming no turn, one stopping every background task, or a queue Resume acts on whatever holds when + * the host reaches it: its payload is shared with every later one, so each press gets its own id. */ export function structuredAgentSessionWriteNamesItsTarget( fingerprintMethod: string, fields: Readonly> ): boolean { + if (fingerprintMethod === 'agentSession.queuedMessagesResume') { + return false + } if (fingerprintMethod !== 'agentSession.cancel') { return true } diff --git a/src/shared/structured-agent-session-outbox-delivery.test.ts b/src/shared/structured-agent-session-outbox-delivery.test.ts new file mode 100644 index 000000000000..bca184044700 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-delivery.test.ts @@ -0,0 +1,91 @@ +// The entry keeps only what its first attempt sent; each request decides the wire field from that +// and the host's capability. Nothing ever waits on the capability. + +import { describe, expect, it } from 'vitest' +import { + createStructuredAgentSessionOutboxEntry, + parseStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { + structuredAgentSessionEntryAttempt, + type StructuredAgentSessionQueueCapability +} from './structured-agent-session-outbox-delivery' + +function entry(overrides: Partial = {}) { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'op-1', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 + }), + ...overrides + } +} + +function host(capability: StructuredAgentSessionQueueCapability, enabled = true) { + return { capability, enabled } +} + +describe('a first attempt', () => { + it('asks to queue only of a host known to queue, with the setting on, for plain text', () => { + const queued = structuredAgentSessionEntryAttempt(entry(), host('supported')) + expect(queued.wire.sentDelivery).toBe('queue-if-active') + expect(queued.stored.sentDelivery).toBe('queue-if-active') + for (const off of [host('unknown'), host('unsupported'), host('supported', false)]) { + const plain = structuredAgentSessionEntryAttempt(entry(), off) + expect(plain.wire.sentDelivery).toBeNull() + expect(plain.stored.sentDelivery).toBeNull() + } + const image = { + ...entry(), + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'image-ref' as const, path: '/tmp/a.png' }] + } + } + expect( + structuredAgentSessionEntryAttempt(image, host('supported')).wire.sentDelivery + ).toBeNull() + const launch = entry({ source: 'launch' }) + expect( + structuredAgentSessionEntryAttempt(launch, host('supported')).wire.sentDelivery + ).toBeNull() + }) +}) + +describe('a replay of an attempted id', () => { + const sentQueued = entry({ lastAttemptAt: 5, sentDelivery: 'queue-if-active' }) + const sentPlain = entry({ lastAttemptAt: 5, sentDelivery: null }) + + it('sends exactly what it first sent, whatever the capability reads or the setting says', () => { + for (const current of [host('unknown'), host('supported', false), host('supported')]) { + expect(structuredAgentSessionEntryAttempt(sentQueued, current).wire.sentDelivery).toBe( + 'queue-if-active' + ) + expect(structuredAgentSessionEntryAttempt(sentPlain, current).wire.sentDelivery).toBeNull() + expect(structuredAgentSessionEntryAttempt(sentQueued, current).stored).toBe(sentQueued) + } + }) + + it('drops the field for a host known not to read it, and keeps what was sent', () => { + const attempt = structuredAgentSessionEntryAttempt(sentQueued, host('unsupported')) + expect(attempt.wire.sentDelivery).toBeNull() + expect(attempt.wire.clientMessageId).toBe('op-1') + expect(attempt.stored).toBe(sentQueued) + }) + + it('stores no intent: a stale `delivery` key is dropped on read, and a main entry sends plain', () => { + const read = parseStructuredAgentSessionOutboxEntry( + { ...entry({ lastAttemptAt: 5 }), delivery: 'queue-if-active' }, + 'session-1' + ) + expect(read !== null && 'delivery' in read).toBe(false) + expect( + read && structuredAgentSessionEntryAttempt(read, host('supported')).wire.sentDelivery + ).toBe(null) + }) +}) diff --git a/src/shared/structured-agent-session-outbox-delivery.ts b/src/shared/structured-agent-session-outbox-delivery.ts new file mode 100644 index 000000000000..28ced3d2fee7 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-delivery.ts @@ -0,0 +1,65 @@ +// Whether an outbox send asks the host to hold it as a draft (`delivery: 'queue-if-active'`). +// +// The one stored fact is `sentDelivery`, what the first attempt put on the wire. An attempted id +// replays it, for fingerprint parity with what the host may have recorded; a never-attempted one +// decides at attempt time. Nothing here ever holds a send: an unknown capability sends plain, as a +// host without queueing always has. + +import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' + +/** What the connected host says about queued messages; `unknown` until it has answered, and after + * a failed probe. */ +export type StructuredAgentSessionQueueCapability = 'unknown' | 'supported' | 'unsupported' + +/** `enabled` is the user's setting. */ +export type StructuredAgentSessionQueueDelivery = { + capability: StructuredAgentSessionQueueCapability + enabled: boolean +} + +/** Whether this entry's next request asks to be queued. An attempted id asks exactly what it sent, + * except of a host known not to queue, which rejects the field before its operation ledger, so + * nothing there was recorded with it. A first attempt asks only of a host known to queue, with + * the setting on, for plain text that is not a launch prompt. */ +export function structuredAgentSessionEntryAsksToQueue( + entry: StructuredAgentSessionOutboxEntry, + host: StructuredAgentSessionQueueDelivery +): boolean { + if (entry.lastAttemptAt !== null) { + return entry.sentDelivery === 'queue-if-active' && host.capability !== 'unsupported' + } + return ( + host.capability === 'supported' && + host.enabled && + entry.source !== 'launch' && + entry.body.blocks.every((block) => block.type === 'text') + ) +} + +/** The next attempt: `stored` is what the entry keeps (a first attempt records what it sent; the + * capability never rewrites that), `wire` the request's own copy, carrying what it sends. */ +export function structuredAgentSessionEntryAttempt( + entry: StructuredAgentSessionOutboxEntry, + host: StructuredAgentSessionQueueDelivery +): { stored: StructuredAgentSessionOutboxEntry; wire: StructuredAgentSessionOutboxEntry } { + const sentDelivery: StructuredAgentSessionOutboxEntry['sentDelivery'] = + structuredAgentSessionEntryAsksToQueue(entry, host) ? 'queue-if-active' : null + const stored = entry.lastAttemptAt !== null ? entry : { ...entry, sentDelivery } + return { + stored, + wire: stored.sentDelivery === sentDelivery ? stored : { ...stored, sentDelivery } + } +} + +/** The queue fields a stored entry carries, read back from storage. */ +export function parseStructuredAgentSessionOutboxQueueFields(entry: { + sentDelivery?: unknown + outlivedStop?: unknown +}): Pick { + return { + ...(entry.sentDelivery === 'queue-if-active' || entry.sentDelivery === null + ? { sentDelivery: entry.sentDelivery } + : {}), + ...(entry.outlivedStop === true ? { outlivedStop: true as const } : {}) + } +} diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index 7d35f9e87202..a79dd8058dd1 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -1,8 +1,9 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off' /** Whether only the user's Retry sends this entry again: a refused one, the one the drain stopped - * on, or one in doubt the unconfirmed probe leaves alone. `NativeChatDeliveryRetry` offers it. */ + * on, one a Stop outlived, or one in doubt the unconfirmed probe leaves alone. `NativeChatDeliveryRetry` offers it. */ function awaitsStructuredAgentSessionRetry( entry: StructuredAgentSessionOutboxEntry, blockedClientMessageId: string | null @@ -10,6 +11,7 @@ function awaitsStructuredAgentSessionRetry( return ( entry.state === 'rejected' || entry.clientMessageId === blockedClientMessageId || + entry.outlivedStop === true || (entry.state === 'unconfirmed' && entry.retryAfterUnknownSubmittedAt !== null) ) } @@ -18,17 +20,45 @@ function unsentStructuredAgentSessionOutboxEntry( submissions: readonly AgentJournalSubmission[], blockedClientMessageId: string | null ): (entry: StructuredAgentSessionOutboxEntry) => boolean { - const held = new Set(submissions.map((submission) => submission.clientMessageId)) + const held = handedOffQueuedMessageIds(submissions) + for (const submission of submissions) { + held.add(submission.clientMessageId) + } return (entry) => !held.has(entry.clientMessageId) && !awaitsStructuredAgentSessionRetry(entry, blockedClientMessageId) } +/** A queue send that has gone out at least once and was not refused, in whatever state it now + * waits: the host may hold it as a paused draft, so a local restore too would put the same text + * in two places. Read from what went on the wire (`sentDelivery`). */ +function attemptedQueueSend(entry: StructuredAgentSessionOutboxEntry): boolean { + return ( + entry.sentDelivery === 'queue-if-active' && + entry.lastAttemptAt !== null && + entry.state !== 'rejected' + ) +} + +/** An attempted queue send a Stop keeps is marked, and its state is left to its answer: nothing + * but the user's Retry sends it again (the drain holds a marked `queued` entry, and the probe + * skips the mark), since a resend onto the session the user just stopped would start a turn if + * the host never got it. */ +function markedOutlivingStop( + entry: StructuredAgentSessionOutboxEntry +): StructuredAgentSessionOutboxEntry { + return attemptedQueueSend(entry) && entry.outlivedStop !== true + ? { ...entry, outlivedStop: true } + : entry +} + /** * What a Stop leaves in the outbox: nothing the journal does not already hold may go out after it, * so every such entry goes, as a message the host withdraws leaves the chat. The send on its way * stays: it reaches the host ahead of the Stop, and it comes back from the host's answer, since - * the agent may already have it. One waiting on Retry keeps it. + * the agent may already have it. One waiting on Retry keeps it, and so does an issued queue send + * that has gone out (a queued receipt or hand-off retires it against the published card), and it + * waits for the user's Retry from then on. */ export function withdrawUnsentStructuredAgentSessionOutboxEntries( entries: readonly StructuredAgentSessionOutboxEntry[], @@ -37,9 +67,14 @@ export function withdrawUnsentStructuredAgentSessionOutboxEntries( inFlightClientMessageId: string | null ): StructuredAgentSessionOutboxEntry[] { const unsent = unsentStructuredAgentSessionOutboxEntry(submissions, blockedClientMessageId) - return entries.filter( - (entry) => entry.clientMessageId === inFlightClientMessageId || !unsent(entry) - ) + return entries + .filter( + (entry) => + entry.clientMessageId === inFlightClientMessageId || + !unsent(entry) || + attemptedQueueSend(entry) + ) + .map(markedOutlivingStop) } /** Whether a Stop has something here to withdraw: a message that would still go out on its own. */ diff --git a/src/shared/structured-agent-session-outbox.queue-delivery.test.ts b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts new file mode 100644 index 000000000000..1416430beb09 --- /dev/null +++ b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts @@ -0,0 +1,161 @@ +// `delivery: 'queue-if-active'` on a send: recorded as the entry's `sentDelivery` by its first +// attempt, persisted, replayed identically, and absent — key and all — from any send an incapable +// host could see (its strict schema refuses unknown keys). + +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { + admitStructuredAgentSessionOutboxEntry, + createStructuredAgentSessionOutboxEntry, + parseStructuredAgentSessionOutboxEntry, + stageStructuredAgentSessionOutboxEntryForSend, + structuredAgentSessionSendMutation, + structuredAgentSessionSendRequest, + type StructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxState +} from './structured-agent-session-outbox' +import { structuredAgentSessionEntryAttempt } from './structured-agent-session-outbox-delivery' +import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' +import { withdrawUnsentStructuredAgentSessionOutboxEntries } from './structured-agent-session-outbox-stop-withdrawal' + +function entry(sentDelivery?: 'queue-if-active') { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'client-1', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 + }), + ...(sentDelivery ? { sentDelivery } : {}) + } +} + +describe('outbox queue delivery', () => { + it('sends `delivery` and digests it into the operation fingerprint, exactly as the host does', () => { + const mutation = structuredAgentSessionSendMutation(entry('queue-if-active'), 3) + expect(mutation.delivery).toBe('queue-if-active') + expect(mutation.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: mutation.body, delivery: 'queue-if-active' } + }) + ) + }) + + it('a plain entry keeps exactly the request an older host has always seen', () => { + const request = structuredAgentSessionSendRequest(entry(), 3) + expect('delivery' in request).toBe(false) + const mutation = structuredAgentSessionSendMutation(entry(), 3) + expect(mutation.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: mutation.body } + }) + ) + }) + + it('persists through a storage round-trip so a retry replays the same operation', () => { + const parsed = parseStructuredAgentSessionOutboxEntry( + JSON.parse(JSON.stringify(entry('queue-if-active'))), + 'session-1' + ) + expect(parsed?.sentDelivery).toBe('queue-if-active') + const plain = parseStructuredAgentSessionOutboxEntry( + JSON.parse(JSON.stringify(entry())), + 'session-1' + ) + expect(plain !== null && 'sentDelivery' in plain).toBe(false) + const foreign = parseStructuredAgentSessionOutboxEntry( + { ...JSON.parse(JSON.stringify(entry())), sentDelivery: 'something-newer' }, + 'session-1' + ) + expect(foreign !== null && 'sentDelivery' in foreign).toBe(false) + }) + + it('Stop keeps every queue send that has gone out, in any state, and marks it for Retry', () => { + // An attempted queue send may already be a host-held draft: withdrawing it locally too would + // put the same text in the composer AND on a card. Read from what went on the wire. + const at = ( + id: string, + state: StructuredAgentSessionOutboxState, + sent?: 'queue-if-active' | null + ) => ({ + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: 'session-1', + text: `text of ${id}`, + attachments: [], + queuedAt: 1 + }), + ...(sent !== undefined ? { lastAttemptAt: 2, sentDelivery: sent } : {}), + state + }) + const next = withdrawUnsentStructuredAgentSessionOutboxEntries( + [ + at('never-left', 'queued'), + // No in-flight id: a `pending` answer freed single-flight before its journal row landed. + at('in-flight', 'dispatching', 'queue-if-active'), + at('in-doubt', 'unconfirmed', 'queue-if-active'), + // Probed back to queued after a lost answer: still out there, not unsent. + at('probed', 'queued', 'queue-if-active'), + at('sent-plain', 'dispatching', null) + ], + [], + null, + null + ) + // Its state is left to its answer: only the mark holds it back. + expect(next.map((entry) => [entry.clientMessageId, entry.state, entry.outlivedStop])).toEqual([ + ['in-flight', 'dispatching', true], + ['in-doubt', 'unconfirmed', true], + ['probed', 'queued', true] + ]) + // The drain never admits the marked one it would otherwise send. + expect(admitStructuredAgentSessionOutboxEntry(next.slice(2), null)).toEqual({ + state: 'blocked', + entry: next[2] + }) + }) + + it('Stop during a first queue attempt, then a settled refusal: rotated and rejected, as without it', () => { + const operations = ['rotated-1', 'rotated-2'] + const attempt = structuredAgentSessionEntryAttempt(entry(), { + capability: 'supported', + enabled: true + }) + const staged = stageStructuredAgentSessionOutboxEntryForSend(attempt.stored, 10) + const refusal = { + ok: false as const, + refusal: { + code: 'agent_session_operation_invalid' as const, + message: 'The message queue is full.' + } + } + const answer = (entries: StructuredAgentSessionOutboxEntry[]) => + disposeStructuredAgentSessionSendResult({ + entries, + entry: attempt.wire, + blockedClientMessageId: null, + result: refusal, + createOperationId: () => operations.shift() ?? 'spent' + }) + const stopped = withdrawUnsentStructuredAgentSessionOutboxEntries( + [staged], + [], + null, + 'client-1' + ) + const withStop = answer(stopped) + const withoutStop = answer([staged]) + expect( + withStop.entries.map((candidate) => [candidate.clientMessageId, candidate.state]) + ).toEqual([['rotated-1', 'rejected']]) + expect( + withoutStop.entries.map((candidate) => [candidate.clientMessageId, candidate.state]) + ).toEqual([['rotated-2', 'rejected']]) + expect(withStop.blockedClientMessageId).toBeNull() + }) +}) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index c14209a716cc..3095a3a0b948 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -12,6 +12,7 @@ import { import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' /** `rejected`: the host settled the send as not delivered. The drain never sends it again on its * own and nothing queues behind it; only the user's Retry does. */ @@ -31,6 +32,13 @@ export type StructuredAgentSessionOutboxEntry = { lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null source?: 'launch' + /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the + * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ + outlivedStop?: true + /** Whether the first attempt asked the host to hold it as a draft (`null`: plain); every replay + * of this id asks the same (structured-agent-session-outbox-delivery). On a request's own copy, + * what that request carries. */ + sentDelivery?: 'queue-if-active' | null /** Why the last attempt did not go through. Lives on the message so it goes when the message * is sent again or delivered, instead of outliving it as a separate error. */ lastFailure?: StructuredAgentSessionAttemptFailure @@ -255,8 +263,9 @@ export function admitStructuredAgentSessionOutboxEntry( if (entry.state === 'unconfirmed' || entry.clientMessageId === blockedClientMessageId) { return { state: 'blocked', entry } } + // A queue send a Stop outlived goes again only on the user's Retry. if (entry.state === 'queued') { - return { state: 'dispatch', entry } + return { state: entry.outlivedStop === true ? 'blocked' : 'dispatch', entry } } } return { state: 'idle', entry: null } @@ -300,6 +309,7 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), + ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } } @@ -307,6 +317,7 @@ export function parseStructuredAgentSessionOutboxEntry( export type StructuredAgentSessionSendMutation = { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem + delivery?: 'queue-if-active' } /** The `agentSession.send` arguments an entry stands for. Typed rather than wire-shaped so a host @@ -315,7 +326,9 @@ export function structuredAgentSessionSendMutation( entry: StructuredAgentSessionOutboxEntry, expectedRuntimeFence: number ): StructuredAgentSessionSendMutation { - const fields = { body: entry.body } + // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. + const delivery = entry.sentDelivery ?? undefined + const fields = { body: entry.body, ...(delivery ? { delivery } : {}) } return { envelope: { sessionId: entry.sessionId, diff --git a/src/shared/structured-agent-session-reducer.queued-messages.test.ts b/src/shared/structured-agent-session-reducer.queued-messages.test.ts new file mode 100644 index 000000000000..dd807f584558 --- /dev/null +++ b/src/shared/structured-agent-session-reducer.queued-messages.test.ts @@ -0,0 +1,189 @@ +// The `queuedMessages` claim: live frames own it, an omitted field means +// "unchanged", history never replaces a newer live list, and absence stays +// absence (an older host makes no claim at all). + +import { describe, expect, it } from 'vitest' +import type { + AgentSessionHistoryPage, + AgentSessionQueuedMessage, + AgentSessionQueuePause, + AgentSessionSubscribeEvent +} from './agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from './structured-agent-session-reducer' + +function queued(id: string, position: number): AgentSessionQueuedMessage { + return { + messageId: id, + position, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + state: 'waiting' + } +} + +function page(queuedMessages?: AgentSessionQueuedMessage[] | null): AgentSessionHistoryPage { + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-a', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 0 }, + hasOlder: false, + hasNewer: false, + ...(queuedMessages !== undefined ? { queuedMessages } : {}) + } +} + +function snapshot( + queuedMessages?: AgentSessionQueuedMessage[] | null, + queuePause?: AgentSessionQueuePause | null +): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-a', + page: page(), + fence: 1, + ...(queuedMessages !== undefined ? { queuedMessages } : {}), + ...(queuePause !== undefined ? { queuePause } : {}) + } +} + +function batch( + sequence: number, + queuedMessages?: AgentSessionQueuedMessage[] | null, + queuePause?: AgentSessionQueuePause | null +): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence }, + items: [], + removedItemIds: [], + submissions: [] + }, + ...(queuedMessages !== undefined ? { queuedMessages } : {}), + ...(queuePause !== undefined ? { queuePause } : {}) + } +} + +function hydrated( + queuedMessages?: AgentSessionQueuedMessage[] | null +): StructuredAgentSessionState { + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: snapshot(queuedMessages) + }) +} + +describe('structured agent session reducer: queuedMessages', () => { + it('adopts the list from a snapshot and keeps it through a batch that omits it', () => { + const state = hydrated([queued('draft-1', 1)]) + expect(state.queuedMessages).toEqual([queued('draft-1', 1)]) + const after = reduceStructuredAgentSession(state, { type: 'event', event: batch(1) }) + expect(after.queuedMessages).toEqual([queued('draft-1', 1)]) + }) + + it('replaces the whole list from a batch that carries one, empty included', () => { + const state = hydrated([queued('draft-1', 1)]) + const grown = reduceStructuredAgentSession(state, { + type: 'event', + event: batch(1, [queued('draft-1', 1), queued('draft-2', 2)]) + }) + expect(grown.queuedMessages).toHaveLength(2) + const drained = reduceStructuredAgentSession(grown, { type: 'event', event: batch(2, []) }) + expect(drained.queuedMessages).toEqual([]) + }) + + it('changes state for a list update at an unchanged cursor and journal', () => { + const state = reduceStructuredAgentSession(hydrated([]), { type: 'event', event: batch(1) }) + const after = reduceStructuredAgentSession(state, { + type: 'event', + event: batch(1, [queued('draft-1', 1)]) + }) + expect(after).not.toBe(state) + expect(after.queuedMessages).toEqual([queued('draft-1', 1)]) + }) + + it('never lets a stale history answer replace a newer live list', () => { + const state = reduceStructuredAgentSession(hydrated([]), { + type: 'event', + event: batch(1, [queued('draft-2', 2)]) + }) + const after = reduceStructuredAgentSession(state, { + type: 'history-page', + page: page([queued('draft-1', 1)]) + }) + expect(after.queuedMessages).toEqual([queued('draft-2', 2)]) + }) + + it('adopts a history page claim when the live stream has made none', () => { + const after = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: page([queued('draft-1', 1)]) + }) + expect(after.queuedMessages).toEqual([queued('draft-1', 1)]) + }) + + it('makes no claim when no frame carried the field (older host)', () => { + const state = hydrated() + expect('queuedMessages' in state).toBe(false) + const after = reduceStructuredAgentSession(state, { type: 'event', event: batch(1) }) + expect('queuedMessages' in after).toBe(false) + }) + + it('a reset re-hydrates the list from its own frame', () => { + const state = hydrated([queued('draft-1', 1)]) + const after = reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'reset', + sessionId: 'session-a', + reset: 'epoch_changed', + page: page(), + fence: 2, + queuedMessages: [] + } + }) + expect(after.queuedMessages).toEqual([]) + }) + + it('carries the queue pause with the list it rode with, and clears it with the next list', () => { + const paused = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: snapshot([queued('draft-1', 1)], { reason: 'stopped' }) + }) + expect(paused.queuePause).toEqual({ reason: 'stopped' }) + // A batch with no list leaves the pause as it was. + const kept = reduceStructuredAgentSession(paused, { type: 'event', event: batch(2) }) + expect(kept.queuePause).toEqual({ reason: 'stopped' }) + // A list published without a pause means the queue drains on its own again. + const resumed = reduceStructuredAgentSession(kept, { + type: 'event', + event: batch(3, [queued('draft-1', 1)]) + }) + expect(resumed.queuePause).toBeNull() + }) + + it('changes state for a pause-only update at an unchanged cursor, journal and list', () => { + const state = reduceStructuredAgentSession(hydrated([queued('draft-1', 1)]), { + type: 'event', + event: batch(1) + }) + const after = reduceStructuredAgentSession(state, { + type: 'event', + event: batch(1, state.queuedMessages ?? null, { reason: 'stopped' }) + }) + expect(after.queuePause).toEqual({ reason: 'stopped' }) + }) +}) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 02c554628fb8..718f4d6a0589 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -7,6 +7,8 @@ import type { AgentSessionBackgroundTaskState, AgentSessionSlashCommand, AgentSessionHistoryPage, + AgentSessionQueuedMessage, + AgentSessionQueuePause, AgentSessionSubscribeEvent, AgentSessionTurnActivity } from './agent-session-wire' @@ -38,6 +40,10 @@ export type StructuredAgentSessionState = { /** The refusal the failed read met, when the host sent one; cleared with `error`. */ readRefusal?: AgentSessionRefusalReference backgroundTasks?: AgentSessionBackgroundTaskState | null + /** Host-held drafts. Absent = no claim yet (older host); `[]`/null = empty. */ + queuedMessages?: AgentSessionQueuedMessage[] | null + /** The queue's pause, published with the list; null when it sends on its own. */ + queuePause?: AgentSessionQueuePause | null commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null /** Absent until a frame from a host that stamps `hostNow` has been applied. */ @@ -80,6 +86,19 @@ function hostClockField( return hostClock ? { hostClock } : {} } +type QueuePublication = Pick + +/** First claim with a list wins, and its pause rides with it; no claim at all leaves both absent + * (older host). */ +function queuePublicationField(...claims: QueuePublication[]): QueuePublication { + for (const claim of claims) { + if (claim.queuedMessages !== undefined) { + return { queuedMessages: claim.queuedMessages, queuePause: claim.queuePause ?? null } + } + } + return {} +} + function replacePage( page: AgentSessionHistoryPage, fence: number | null, @@ -189,6 +208,8 @@ export function reduceStructuredAgentSession( return { ...replacePage(action.page, action.page.fence ?? null, state.backgroundTasks, state.activity), commands: state.commands, + // Live subscription state stays authoritative over a possibly stale history answer. + ...queuePublicationField(state, action.page), ...hostClockField(action.page.hostNow, receivedAt, state.hostClock) } } @@ -222,6 +243,8 @@ export function reduceStructuredAgentSession( return { ...replacePage(event.page, event.fence, event.backgroundTasks, event.activity), commands: event.commands, + // A snapshot omits the list when unchanged since the last frame sent to this subscriber. + ...queuePublicationField(event, event.page, state), ...hostClockField(event.hostNow, receivedAt, state.hostClock) } } @@ -244,6 +267,8 @@ export function reduceStructuredAgentSession( journalUnchanged && (event.fence === undefined || event.fence === state.fence) && (event.commands === undefined || event.commands === state.commands) && + (event.queuedMessages === undefined || event.queuedMessages === state.queuedMessages) && + (event.queuePause === undefined || event.queuePause === state.queuePause) && backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) && activity?.turnId === state.activity?.turnId && activity?.text === state.activity?.text && @@ -278,6 +303,7 @@ export function reduceStructuredAgentSession( error: undefined, readRefusal: undefined, commands: event.commands !== undefined ? event.commands : state.commands, + ...queuePublicationField(event, state), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(activity !== undefined ? { activity } : {}), ...(lostTurnRow ? { unloadedTurnRevisions: (state.unloadedTurnRevisions ?? 0) + 1 } : {}), diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index ce6143369ecd..27e37127d99e 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -95,14 +95,16 @@ function refusedRedelivery( } /** Whether the journal already answers a send still in flight, so its own reply adds nothing: the - * host holds the message, or rejected it — a later `pending` reply must not undo that. */ + * host holds the message, rejected it, or handed it off as a queued draft — a later `pending` + * reply must not undo that. */ export function journalAnswersInFlightSend( submissions: readonly AgentJournalSubmission[], clientMessageId: string | null ): boolean { return submissions.some( (submission) => - submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown' + (submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown') || + (clientMessageId !== null && submission.queuedMessageId === clientMessageId) ) } @@ -240,6 +242,15 @@ export function disposeStructuredAgentSessionSendResult( } } const submission = result.value.submission + // A replay of a send the host queued and then handed off answers with that hand-off, under its + // own id: the host owns the message, and the entry leaves as the reconcile drops it. + if (submission.queuedMessageId === input.entry.clientMessageId) { + return { + entries: dropEntry(input), + error: null, + blockedClientMessageId: input.blockedClientMessageId + } + } if (refusedRedelivery(input.entry, submission)) { return { entries: dropEntry(input),