diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 72b78e9e5ab4..b44dcb2852a7 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -15,6 +15,7 @@ "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", + "../src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts", "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/preload/**/*", "../src/shared/**/*", diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 5eb53046128b..388cf0227880 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -157,6 +157,48 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) + it('a command card never steers: Send only while the agent is idle, and no menu', async () => { + const onSend = vi.fn(async () => true) + const mounted = await mount({ + cards: [ + card({ messageId: 'working', text: '/compact', command: true, waitsForAgent: true }), + card({ messageId: 'idle', text: '/compact', command: true }) + ], + onSend + }) + expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual(['Send']) + await act(async () => { + mounted.root.findByProps({ accessibilityLabel: 'Send this message' }).props.onPress() + }) + expect(onSend).toHaveBeenCalledWith('idle') + // Its menu holds only Edit, which a command does not take. + expect(mounted.root.findAllByProps({ accessibilityLabel: 'More actions' })).toHaveLength(0) + expect( + mounted.root.findAllByProps({ accessibilityLabel: 'Delete this queued message' }) + ).toHaveLength(2) + }) + + it("a paused command card's ways out are Delete and the queue's Resume", async () => { + const onResume = vi.fn(async () => true) + const onDelete = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'compact', text: '/compact', command: true })], + pause: { reason: 'stopped' }, + onResume, + onDelete + }) + await act(async () => { + mounted.root + .findByProps({ accessibilityLabel: 'Resume sending the queued messages' }) + .props.onPress() + }) + expect(onResume).toHaveBeenCalledOnce() + await act(async () => { + mounted.root.findByProps({ accessibilityLabel: 'Delete this queued message' }).props.onPress() + }) + expect(onDelete).toHaveBeenCalledWith('compact') + }) + it("holds Steer while a person's Stop ends the turn; Delete still works", async () => { const onSend = vi.fn(async () => true) const onDelete = vi.fn(async () => true) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index fa44fdd01fda..3aff7ff1bd80 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -110,7 +110,7 @@ export function MobileNativeChatQueuedMessages({ const returned = card.state === 'returned' // "Steer" submits beside the running turn, the paused queue's cards too; a card whose // own send failed, or a returned one, is sent again. - const steers = !returned && !card.paused + const steers = !returned && !card.paused && !card.command return ( ) : null} - [ - styles.textAction, - pressed && styles.pressed, - (busy || steerHeld) && styles.disabled - ]} - disabled={busy || steerHeld} - onPress={() => void run(card.messageId, onSend)} - > - {steers ? ( - - ) : ( - - )} - {steers ? 'Steer' : 'Send'} - + {/* A command never steers: its Send shows only while the agent is idle. */} + {card.waitsForAgent ? null : ( + [ + styles.textAction, + pressed && styles.pressed, + (busy || steerHeld) && styles.disabled + ]} + disabled={busy || steerHeld} + onPress={() => void run(card.messageId, onSend)} + > + {steers ? ( + + ) : ( + + )} + {steers ? 'Steer' : 'Send'} + + )} - [ - styles.iconAction, - pressed && styles.pressed, - busy && styles.disabled - ]} - disabled={busy} - onPress={() => setMenuFor(card.messageId)} - > - - + {/* The menu holds only Edit, which a command does not take. */} + {card.command ? null : ( + [ + styles.iconAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => setMenuFor(card.messageId)} + > + + + )} ) })} diff --git a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx index 41dc8b5d11f5..05decb191a55 100644 --- a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx +++ b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx @@ -44,6 +44,7 @@ vi.mock('./use-mobile-native-chat-send-error', () => ({ message: null, show: () => {}, clear: () => {}, + keepWhile: () => {}, bannerMountedRef: { current: false } }) })) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 74980499eb9c..56a742f33733 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -12,6 +12,7 @@ import type { } from '../../../src/shared/native-chat-turn-status' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatCommandRefusalCauses } from './use-mobile-native-chat-send-error' import type { MobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' @@ -45,6 +46,8 @@ export type MobileNativeChatController = { nativeChatSettledTurns: NativeChatSettledTurns | null /** Structured lane: the journal that places each transcript row in its turn. */ nativeChatTurnJournal: NativeChatTurnJournal | null + /** What a refused command's line stands on. */ + nativeChatCommandRefusalCauses: MobileNativeChatCommandRefusalCauses nativeChatCanStop: boolean nativeChatStreamingText?: string /** Agent mid-turn, regardless of whether chat is the visible view. */ diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index f24470234903..5e933c94e49b 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -88,6 +88,7 @@ const HOST_SUPPORT = { promptCancel: true, questionAnswers: true, queuedMessages: true, + queuedCommands: false, statusFeed: true, quietRepeatedStop: true } diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts new file mode 100644 index 000000000000..5c6ccec5ab12 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -0,0 +1,40 @@ +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import type { MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatSession } from './use-mobile-native-chat-session' +import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' +import type { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' +import type { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' +import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' +import type { MobileNativeChatCommandRefusalCauses } from './use-mobile-native-chat-send-error' +import type { MobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' +import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' + +export type StructuredMobileSession = ReturnType & + ReturnType & { + session: MobileNativeChatSession + isWorking: boolean + turnId: string | null + /** What labels the live turn's one indicator row. */ + turnIndicator: NativeChatLiveTurnIndicator + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileSendAttachment[] + ) => Promise + cancel: () => void + permission: MobileChatPermission | null + question: MobileChatQuestion | null + respondPermission: (optionId: string) => Promise + respondQuestion: (answer: string) => Promise + cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise + /** The queued-draft cards and their actions, from any host that publishes them. */ + queued: MobileStructuredQueuedMessageControls + commandRefusalCauses: MobileNativeChatCommandRefusalCauses + /** Running child work for the strip above the composer, as desktop shows it. */ + backgroundTasks: MobileStructuredBackgroundTasks + /** Where this chat's `::orca-visual` lines read their HTML from; null without a client. */ + visualSource: MobileNativeChatVisualSource | null + } diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index b6e91fa8a7b4..e5debf14376a 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY @@ -14,6 +15,7 @@ describe('structuredAgentSessionHostSupport', () => { promptCancel: false, questionAnswers: false, queuedMessages: false, + queuedCommands: false, quietRepeatedStop: false, statusFeed: false } @@ -27,6 +29,9 @@ describe('structuredAgentSessionHostSupport', () => { expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) ).toEqual({ ...none, queuedMessages: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, queuedCommands: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY]) ).toEqual({ ...none, quietRepeatedStop: true }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index cb5dcc5c8ec2..84c329e63738 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -1,6 +1,7 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY @@ -12,6 +13,8 @@ export type StructuredAgentSessionHostSupport = { questionAnswers: boolean /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ queuedMessages: boolean + /** A /compact sent while the agent works waits as a card; an older host refuses it. */ + queuedCommands: boolean /** The host publishes every session's status on one stream. A host from before phones could * read it refuses the call, which reads the same as its absence. */ statusFeed: boolean @@ -26,6 +29,7 @@ export function structuredAgentSessionHostSupport( promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), + queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY), statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY) } diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index 8a54140a7dc3..4d62e05da9f2 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -32,7 +32,8 @@ function setup() { setOption: vi.fn(async () => true), conversationCommands: ['clear', 'compact'] }, - canRun: () => true, + busy: () => null, + waitsInLine: () => false, onError: vi.fn(), timeoutMs: 15000 } @@ -102,7 +103,7 @@ describe('mobile structured conversation commands', () => { input.text = '/compact instructions' } if (reason === 'pending work') { - input.canRun = () => false + input.busy = () => 'working' } expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') expect(sendRequest).not.toHaveBeenCalled() @@ -119,6 +120,84 @@ describe('mobile structured conversation commands', () => { expect(fields.command).toBe('clear') expect(asyncStorage.setItem).not.toHaveBeenCalled() }) + it('a /compact the host holds in line skips the busy check, asks to wait, and shows nothing', async () => { + const { input, sendRequest } = setup() + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { + ok: true, + value: { + command: 'compact', + state: 'completed', + queued: { messageId: 'op', position: 1, state: 'waiting' } + } + } + }) + input.busy = () => 'working' + input.waitsInLine = (command) => command === 'compact' + expect(await dispatchMobileStructuredCommand(input)).toBe('accepted') + const fields = requestFields(sendRequest.mock.calls[0]) + expect(fields).toMatchObject({ command: 'compact', delivery: 'queue-if-active' }) + expect(input.onError).not.toHaveBeenCalled() + // A /clear never waits: the busy check still answers it. + expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') + expect(sendRequest).toHaveBeenCalledOnce() + }) + it('a /clear while the agent works says so in plain words', async () => { + const { input, sendRequest } = setup() + input.busy = () => 'working' + expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + "The agent is still working. Run /clear when it's done.", + 'working' + ) + input.busy = () => 'prompt' + expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + "Answer the agent's question or approval, then run /clear.", + 'prompt' + ) + input.busy = () => 'working' + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + "The agent is still working. Run /compact when it's done.", + 'working' + ) + expect(sendRequest).not.toHaveBeenCalled() + }) + it("a host's refusal names its cause only when the phone showed it at the press", async () => { + const { input, sendRequest } = setup() + const refused = { + ok: true, + result: { + ok: true, + value: { + command: 'compact', + state: 'completed', + error: "The agent is still working. Run /compact when it's done.", + failure: { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } + } + } + } + } + sendRequest.mockResolvedValue(refused) + input.waitsInLine = () => true + input.busy = () => 'working' + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + "The agent is still working. Run /compact when it's done.", + 'working' + ) + // Ahead of the phone: said as any failure, so it can't go before it is read. + input.busy = () => null + expect(await dispatchMobileStructuredCommand(input)).toBe('rejected') + expect(input.onError).toHaveBeenLastCalledWith( + "The agent is still working. Run /compact when it's done.", + undefined + ) + }) it('keeps ordinary messages on the existing send path', async () => { const { input, sendRequest } = setup() expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull() diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts index ebe30fe88031..d8012a5555a7 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -1,13 +1,32 @@ -import type { AgentSessionConversationCommandResult } from '../../../src/shared/agent-session-conversation-command' +import type { + AgentSessionConversationCommand, + AgentSessionConversationCommandResult +} from '../../../src/shared/agent-session-conversation-command' import { dispatchStructuredAgentSessionComposerCommand, isStructuredAgentSessionComposerCommand, + type StructuredAgentSessionCommandRefusalCause, type StructuredAgentSessionComposerOptions } from '../../../src/shared/structured-agent-session-composer' +import { structuredAgentSessionCommandHostRefusalCause } from '../../../src/shared/structured-agent-session-command-refusal-cause' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc' +/** What the person sees and can do, in the words the desktop uses. */ +function busyCommandText( + command: AgentSessionConversationCommand, + busy: 'working' | 'prompt' +): string { + const clear = command === 'clear' + return agentSessionWriteNoticeEnglish( + busy === 'prompt' + ? [clear ? 'clearAfterAnswer' : 'compactAfterAnswer'] + : ['agentStillWorking', clear ? 'runClearWhenDone' : 'runCompactWhenDone'] + ) +} + export async function dispatchMobileStructuredCommand(input: { text: string hasAttachments: boolean @@ -16,8 +35,12 @@ export async function dispatchMobileStructuredCommand(input: { fence: number pending: { current: boolean } controller: StructuredAgentSessionComposerOptions - canRun: () => boolean - onError: (message: string) => void + /** What the agent still has in flight that refuses a command now; null when nothing does. */ + busy: () => 'working' | 'prompt' | null + /** The host holds this command as a card behind work in flight, so nothing here holds it. */ + waitsInLine: (command: AgentSessionConversationCommand) => boolean + /** `refusedWhile`: what the phone showed the refused command waiting on. */ + onError: (message: string, refusedWhile?: StructuredAgentSessionCommandRefusalCause) => void timeoutMs: number }): Promise { if (input.pending.current) { @@ -34,11 +57,11 @@ export async function dispatchMobileStructuredCommand(input: { const outcome = await dispatchStructuredAgentSessionComposerCommand(input.text, { ...input.controller, runConversationCommand: async (command) => { - if (!input.canRun()) { - return { - accepted: false, - error: 'Wait for pending work to finish before using this command.' - } + const shown = input.busy() + const waitsInLine = input.waitsInLine(command) + const busy = waitsInLine ? null : shown + if (busy) { + return { accepted: false, error: busyCommandText(command, busy), refusedWhile: busy } } input.pending.current = true try { @@ -49,7 +72,7 @@ export async function dispatchMobileStructuredCommand(input: { expectedRuntimeFence: input.fence, method: 'agentSession.conversationCommand', fingerprintMethod: 'agentSession.conversationCommand', - fields: { command }, + fields: waitsInLine ? { command, delivery: 'queue-if-active' } : { command }, timeoutMs: Math.max(input.timeoutMs, 195_000) }) if ( @@ -62,16 +85,25 @@ export async function dispatchMobileStructuredCommand(input: { error: 'Conversation operation was not confirmed.' } } - return result.status === 'accepted' - ? { accepted: !result.value.error, error: result.value.error ?? null } - : { accepted: false, error: result.message } + if (result.status !== 'accepted') { + return { accepted: false, error: result.message } + } + // A refusal names its cause only when the phone showed it, as on desktop. + const cause = structuredAgentSessionCommandHostRefusalCause(result.value) + return { + accepted: !result.value.error, + error: result.value.error ?? null, + ...(result.value.error && cause !== undefined && cause === shown + ? { refusedWhile: cause } + : {}) + } } finally { input.pending.current = false } } }) if (outcome.error) { - input.onError(outcome.error) + input.onError(outcome.error, outcome.refusedWhile) } return unknown ? 'unknown' : outcome.accepted ? 'accepted' : 'rejected' } diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 8ead70b20275..1c7d217368e7 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -30,6 +30,62 @@ function draft(overrides: Partial & { messageId: stri } describe('mobileQueuedMessageCards', () => { + it('marks a /compact card as a command, its text as typed', () => { + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'c', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + }) + ], + [], + { pendingPrompt: false } + ) + expect(card).toMatchObject({ text: '/compact', command: true, caption: null }) + expect(card).not.toHaveProperty('waitsForAgent') + expect( + mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { pendingPrompt: false })[0] + ).not.toHaveProperty('command') + const compact = draft({ + messageId: 'c', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + }) + expect( + mobileQueuedMessageCards([compact], [], { pendingPrompt: false, agentWorking: true })[0] + ).toMatchObject({ command: true, waitsForAgent: true }) + }) + + it("a send-failed command card's caption names Send only when Send is there", () => { + const failed = draft({ + messageId: 'c', + paused: true, + pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + }) + expect( + mobileQueuedMessageCards([failed], [], { pendingPrompt: false, agentWorking: true })[0] + ?.caption + ).toBe("Couldn't send — tap Send to retry once the agent finishes") + expect(mobileQueuedMessageCards([failed], [], { pendingPrompt: false })[0]?.caption).toBe( + "Couldn't send — tap Send to retry" + ) + }) + it('renders nothing without a published list', () => { expect(mobileQueuedMessageCards(null, [], { pendingPrompt: false })).toEqual([]) expect(mobileQueuedMessageCards([], [], { pendingPrompt: false })).toEqual([]) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index d65a73388c3d..46d019f132be 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -27,6 +27,10 @@ export type MobileQueuedMessageCard = { caption: string | null /** "From " on another agent's card; null on the person's. */ attribution: string | null + /** A conversation command such as /compact: it never steers into a running turn. */ + command?: true + /** A command card while the agent works: it offers no send until the agent is idle. */ + waitsForAgent?: true } function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { @@ -53,9 +57,11 @@ function returnedCaption( } /** One card's own hold: a failed conversion; the queue's pause is the list's first row. */ -function pausedCaption(reason: string | undefined): string { +function pausedCaption(reason: string | undefined, waitsForAgent: boolean): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { - return "Couldn't send — tap Send to retry" + return waitsForAgent + ? "Couldn't send — tap Send to retry once the agent finishes" + : "Couldn't send — tap Send to retry" } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' @@ -93,7 +99,7 @@ export function mobileQueuePauseLabel(pause: Pick[], - facts: { pendingPrompt: boolean; queuePaused?: boolean } + facts: { pendingPrompt: boolean; queuePaused?: boolean; agentWorking?: boolean } ): MobileQueuedMessageCard[] { if (!queuedMessages || queuedMessages.length === 0) { return [] @@ -116,7 +122,10 @@ export function mobileQueuedMessageCards( draft.state === 'returned' ? returnedCaption(draft) : paused - ? pausedCaption(draft.pausedReason) + ? pausedCaption( + draft.pausedReason, + draft.body.command !== undefined && facts.agentWorking === true + ) : behindReturned ? 'Waiting — a message ahead needs attention' : facts.queuePaused @@ -134,7 +143,13 @@ export function mobileQueuedMessageCards( draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), caption, - attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from)) + attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from)), + ...(draft.body.command !== undefined + ? { + command: true as const, + ...(facts.agentWorking ? { waitsForAgent: true as const } : {}) + } + : {}) }) if (draft.state === 'returned') { behindReturned = true diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 7e0c29ecb1ea..9a7328c8450c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -40,7 +40,8 @@ export function useMobileNativeChatController(args: { connState: ConnectionState /** Host capability fact from the shared runtime status probe. */ agentSessionHostSupport?: StructuredAgentSessionHostSupport | null - onSendError: (message: string) => void + /** Forwarded to the session lane, a refusal's cause with it. */ + onSendError: Parameters[0]['onSendError'] /** Retires a held failure banner. Any accepted chat write clears it — a delivered * answer or permission reply must not sit under a stale "not sent". */ onSendResolved: () => void @@ -300,6 +301,8 @@ export function useMobileNativeChatController(args: { nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null, + // Only the structured lane names a refusal's cause; the bridge lane's starved ones drop none. + nativeChatCommandRefusalCauses: structuredNativeChat.commandRefusalCauses, nativeChatCanStop: activeChatStructured ? structuredNativeChat.turnId !== null : nativeChatAgentWorking, diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts index 49ca07198d03..c105a0b54373 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.test.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts @@ -1,7 +1,10 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useMobileNativeChatSendError } from './use-mobile-native-chat-send-error' +import { + useMobileNativeChatSendError, + type MobileNativeChatCommandRefusalCauses +} from './use-mobile-native-chat-send-error' type HookApi = ReturnType @@ -13,13 +16,17 @@ describe('useMobileNativeChatSendError', () => { function Harness({ scopeKey, - bannerMounted = true + bannerMounted = true, + causes = null }: { scopeKey: string | null bannerMounted?: boolean + causes?: MobileNativeChatCommandRefusalCauses | null }): null { const api = useMobileNativeChatSendError({ scopeKey, showToast }) api.bannerMountedRef.current = bannerMounted + // As the route does each render, with what the chat shows. + api.keepWhile(causes) apiRef.current = api return null } @@ -49,6 +56,48 @@ describe('useMobileNativeChatSendError', () => { vi.useRealTimers() }) + async function showing(causes: MobileNativeChatCommandRefusalCauses): Promise { + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-1', causes })) + }) + } + + it('a /clear refused while the agent works goes when the agent stops, and stays gone', async () => { + const line = "The agent is still working. Run /clear when it's done." + await render() + await showing({ working: true, prompt: false }) + await act(async () => api().show(line, 'working')) + expect(api().message).toBe(line) + await showing({ working: true, prompt: false }) + expect(api().message).toBe(line) + + await showing({ working: false, prompt: false }) + expect(api().message).toBeNull() + // Dropped, not hidden: the agent working again is not what that press was refused for. + await showing({ working: true, prompt: false }) + expect(api().message).toBeNull() + }) + + it('a refusal behind a question goes once it is answered', async () => { + await render() + await showing({ working: false, prompt: true }) + await act(async () => + api().show("Answer the agent's question or approval, then run /clear.", 'prompt') + ) + await showing({ working: false, prompt: false }) + expect(api().message).toBeNull() + }) + + it('a failure naming nothing the phone shows, or background tasks it cannot see, stays', async () => { + await render() + await act(async () => api().show('Message not sent')) + await showing({ working: false, prompt: false }) + expect(api().message).toBe('Message not sent') + await act(async () => api().show('Background tasks are still running.', 'background')) + await showing({ working: false, prompt: false }) + expect(api().message).toBe('Background tasks are still running.') + }) + it('holds a failure for four seconds, then drops it', async () => { await render() await act(async () => api().show('a')) diff --git a/mobile/src/session/use-mobile-native-chat-send-error.ts b/mobile/src/session/use-mobile-native-chat-send-error.ts index 359b94a8336e..d0b7b0427307 100644 --- a/mobile/src/session/use-mobile-native-chat-send-error.ts +++ b/mobile/src/session/use-mobile-native-chat-send-error.ts @@ -1,4 +1,16 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import type { StructuredAgentSessionCommandRefusalCause } from '../../../src/shared/structured-agent-session-composer' + +/** `refusedWhile`: a refused command's cause, which its line is said only while the chat shows. */ +export type MobileNativeChatSendErrorReporter = ( + message: string, + refusedWhile?: StructuredAgentSessionCommandRefusalCause +) => void + +/** What the phone's chat shows a refused command waiting on. */ +export type MobileNativeChatCommandRefusalCauses = Readonly< + Partial> +> const NATIVE_CHAT_SEND_ERROR_HOLD_MS = 4000 const NATIVE_CHAT_SEND_ERROR_TOAST_MS = 1600 @@ -13,12 +25,17 @@ export function useMobileNativeChatSendError(args: { showToast: (message: string, durationMs?: number) => void }): { message: string | null - show: (message: string) => void + show: MobileNativeChatSendErrorReporter clear: () => void + /** Called each render with what the chat shows: a refusal whose cause has ended is dropped. */ + keepWhile: (causes: MobileNativeChatCommandRefusalCauses | null) => void /** Set by the route each render; gates banner vs toast. */ bannerMountedRef: MutableRefObject } { const [message, setMessage] = useState(null) + const [refusedWhile, setRefusedWhile] = useState< + StructuredAgentSessionCommandRefusalCause | undefined + >(undefined) const timerRef = useRef | null>(null) const bannerMountedRef = useRef(false) const showToastRef = useRef(args.showToast) @@ -44,7 +61,7 @@ export function useMobileNativeChatSendError(args: { setMessage(null) }, [clearTimer, scopeKey]) const show = useCallback( - (next: string) => { + (next: string, cause?: StructuredAgentSessionCommandRefusalCause) => { // Why: deferred failures can land after the user left chat (banner unmounted) // or moved to another tab, where the banner belongs to a different terminal — // both must fall back to the toast instead of being swallowed or misattributed. @@ -54,6 +71,7 @@ export function useMobileNativeChatSendError(args: { } clearTimer() setMessage(next) + setRefusedWhile(cause) timerRef.current = setTimeout(() => { timerRef.current = null setMessage(null) @@ -76,5 +94,12 @@ export function useMobileNativeChatSendError(args: { }, [clearTimer] ) - return { message, show, clear, bannerMountedRef } + const keepWhile = (causes: MobileNativeChatCommandRefusalCauses | null) => { + // Dropped, not hidden: the cause coming back later is not what this refusal was about. + if (message !== null && refusedWhile !== undefined && causes?.[refusedWhile] === false) { + setMessage(null) + setRefusedWhile(undefined) + } + } + return { message, show, clear, keepWhile, bannerMountedRef } } diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index 15f68f0758b3..314c95a71092 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -4,6 +4,7 @@ import type { ConnectionState } from '../transport/types' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' /** Mounts both transcript sources and hands back the one this tab's lane owns. * Both hooks always run (hook order is fixed); the inactive lane is starved of @@ -38,7 +39,7 @@ export function useMobileNativeChatSessionLane({ appendComposerTextRef: { readonly current: (text: string) => boolean } enabled: boolean connState: ConnectionState - onSendError: (message: string) => void + onSendError: MobileNativeChatSendErrorReporter /** Called on any accepted queued-card action; retires the route's failure banner. */ onActionResolved?: () => void }): { diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 4dc1743e2219..075ee17c19ef 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -79,6 +79,7 @@ export function useMobileSessionNativeChatDictation( }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController nativeChatSendError.bannerMountedRef.current = showNativeChat + nativeChatSendError.keepWhile(nativeChatController.nativeChatCommandRefusalCauses) const nativeChatOverlayInputLockReason = activeSessionTab?.type === 'agent-session' ? connState === 'connected' diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 328a1effdd30..7a14895fd66e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -122,6 +122,7 @@ function Harness({ promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported, queuedMessages: false, + queuedCommands: false, statusFeed: false, quietRepeatedStop: false }, diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index 163cb20246f4..7a0c57b9b352 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -16,6 +16,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { promptCancel: false, questionAnswers: false, queuedMessages: true, + queuedCommands: false, statusFeed: false, quietRepeatedStop: false } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index b5f17dbbfa49..6ad65cf993b1 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' @@ -17,12 +16,9 @@ import { projectStructuredPermission, projectStructuredQuestion } from './mobile-structured-agent-prompts' -import type { RpcClient } from '../transport/rpc-client' +import type { StructuredMobileSession } from './mobile-structured-agent-session-contract' import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read' -import type { MobileChatPermission } from './mobile-native-chat-permission' -import type { MobileChatQuestion } from './mobile-native-chat-question' -import type { MobileNativeChatSession } from './use-mobile-native-chat-session' -import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' +import type { RpcClient } from '../transport/rpc-client' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' import { useMobileStructuredSessionHostStopping } from './use-mobile-structured-session-host-stopping' @@ -38,44 +34,11 @@ import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mu import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' import { mobileStructuredSendQueues, - useMobileStructuredSendWithOutcome, - type StructuredMobileSendAttachment + useMobileStructuredSendWithOutcome } from './use-mobile-structured-send-with-outcome' -import { - useMobileStructuredQueuedMessageControls, - type MobileStructuredQueuedMessageControls -} from './use-mobile-structured-queued-message-controls' -import { - useMobileStructuredBackgroundTasks, - type MobileStructuredBackgroundTasks -} from './use-mobile-structured-background-tasks' - -type StructuredMobileSession = ReturnType & - ReturnType & { - session: MobileNativeChatSession - isWorking: boolean - turnId: string | null - /** What labels the live turn's one indicator row. */ - turnIndicator: NativeChatLiveTurnIndicator - sendWithOutcome: ( - text: string, - images?: string[], - deadline?: number, - attachments?: readonly StructuredMobileSendAttachment[] - ) => Promise - cancel: () => void - permission: MobileChatPermission | null - question: MobileChatQuestion | null - respondPermission: (optionId: string) => Promise - respondQuestion: (answer: string) => Promise - cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise - /** The queued-draft cards and their actions, from any host that publishes them. */ - queued: MobileStructuredQueuedMessageControls - /** Running child work for the strip above the composer, as desktop shows it. */ - backgroundTasks: MobileStructuredBackgroundTasks - /** Where this chat's `::orca-visual` lines read their HTML from; null without a client. */ - visualSource: MobileNativeChatVisualSource | null - } +import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' +import { useMobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' +import { useMobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' export function useMobileStructuredAgentSession(args: { client: RpcClient | null @@ -90,7 +53,7 @@ export function useMobileStructuredAgentSession(args: { agent: string | null /** The active pane's live composer; Edit copies a card's text through it. */ appendComposerText?: (text: string) => boolean - onSendError: (message: string) => void + onSendError: MobileNativeChatSendErrorReporter /** Called on any accepted queued-card action; retires the route's failure banner. */ onActionResolved?: () => void }): StructuredMobileSession { @@ -108,6 +71,8 @@ export function useMobileStructuredAgentSession(args: { } = args // Only a host that queues sends gets the delivery field; any host's published cards show. const queueCapable = hostSupport?.queuedMessages === true + // A /compact waits in line only where its card renders. + const commandsWait = queueCapable && hostSupport?.queuedCommands === true const promptCancelSupported = hostSupport?.promptCancel ?? null const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) @@ -153,6 +118,7 @@ export function useMobileStructuredAgentSession(args: { sessionId, enabled, queueCapable, + commandsWait, stateRef, commandPending: commandPendingRef, controller: sendController, @@ -238,12 +204,21 @@ export function useMobileStructuredAgentSession(args: { () => state.items.find(pendingStructuredQuestion) ?? null, [state.items] ) + // What a refused command's line on the phone stands on. + const commandRefusalCauses = useMemo( + () => ({ + working: turnId !== null, + prompt: approvalPrompt !== null || questionPrompt !== null + }), + [approvalPrompt, questionPrompt, turnId] + ) const queued = useMobileStructuredQueuedMessageControls({ sessionKey, queuedMessages, queuePause, submissions: state.submissions, pendingPrompt: approvalPrompt !== null || questionPrompt !== null, + agentWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), mutate, appendComposerText, onSendError, @@ -308,6 +283,7 @@ export function useMobileStructuredAgentSession(args: { respondPermission, respondQuestion, queued, + commandRefusalCauses, backgroundTasks } } diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index a786eeacbe09..74518947e9bd 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -50,6 +50,8 @@ export function useMobileStructuredQueuedMessageControls(args: { queuePause: MobileQueuePause submissions: readonly AgentJournalSubmission[] pendingPrompt: boolean + /** The chat shows the agent working: a command card offers no send then. */ + agentWorking?: boolean mutate: MobileStructuredAgentMutate /** The active pane's live composer, Edit's copy target; absent = Edit refuses. False when * nothing was copied. */ @@ -69,13 +71,15 @@ export function useMobileStructuredQueuedMessageControls(args: { sessionKey, submissions } = args + const agentWorking = args.agentWorking === true const cards = useMemo( () => mobileQueuedMessageCards(queuedMessages, submissions, { pendingPrompt, + agentWorking, queuePaused: queuePause !== null }), - [pendingPrompt, queuePause, queuedMessages, submissions] + [agentWorking, pendingPrompt, queuePause, queuedMessages, submissions] ) const resolved = useCallback( (accepted: boolean): boolean => { @@ -135,7 +139,8 @@ export function useMobileStructuredQueuedMessageControls(args: { // leaves the card beside the copy, visibly, never a silent duplicate. No // copy (no composer yet, or an empty card) means no Delete: Edit never // removes text it did not keep. - if (!card || !appendComposerText?.(card.text)) { + // A command's text is not a draft: edited, it would become a message. + if (!card || card.command || !appendComposerText?.(card.text)) { return false } onCopied?.() diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 4a7b1e67470c..c6c8e9604e07 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -15,6 +15,7 @@ import type { StructuredAgentSessionState } from '../../../src/shared/structured import type { RpcClient } from '../transport/rpc-client' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' +import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { @@ -41,13 +42,15 @@ export function useMobileStructuredSendWithOutcome(args: { sessionId: string | null enabled: boolean queueCapable: boolean + /** The host holds a /compact sent while the agent works as a card. */ + commandsWait: boolean stateRef: { readonly current: StructuredAgentSessionState } commandPending: { current: boolean } controller: Pick< StructuredAgentSessionComposerOptions, 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' > - onSendError: (message: string) => void + onSendError: MobileNativeChatSendErrorReporter }): ( text: string, images?: string[], @@ -58,6 +61,7 @@ export function useMobileStructuredSendWithOutcome(args: { agent, client, commandPending, + commandsWait, controller, enabled, onSendError, @@ -98,11 +102,19 @@ export function useMobileStructuredSendWithOutcome(args: { agent: agent === 'claude' ? 'claude' : 'codex', ...controller }, - canRun: () => - !runningStructuredAgentSessionTurnId(stateRef.current) && - !stateRef.current.items.some( + busy: () => + stateRef.current.items.some( (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) - ), + ) + ? 'prompt' + : runningStructuredAgentSessionTurnId(stateRef.current) + ? 'working' + : null, + // A card waiting on a prompt nothing here can answer would hold it forever. + waitsInLine: (command) => + command === 'compact' && + commandsWait && + !pendingPromptsAllUnanswerableHere(stateRef.current.items), onError: onSendError, timeoutMs }) @@ -130,6 +142,7 @@ export function useMobileStructuredSendWithOutcome(args: { agent, client, commandPending, + commandsWait, controller, enabled, onSendError, diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index c2cdf5d5114b..79f761f5511e 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -265,6 +265,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { promptCancel: false, questionAnswers: false, queuedMessages: false, + queuedCommands: false, quietRepeatedStop: false, statusFeed }, diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index c56e4f47d45d..28bfdb3358b9 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -33,10 +33,8 @@ export function consumedSubmissionWasRejected( export type RejectedDraftSettlement = { state: 'returned' | 'waiting' } /** - * Where no one failed the user, the draft goes back to waiting, under whatever pauses the queue: a - * Stop's, or, when a restart or a close cut it short, the reopen's, so it follows the chat's next - * turn like every card the chat closed with. A returned card would block the drafts behind it on a - * failure that never happened. A failure returns the card with its refusal for the user to act on. + * A withdrawal waits at its original position under the queue's pause; a failure returns the + * card, except a command refused in its own turn, whose row already reports it. */ export function rejectedDraftSettlement( rejected: Pick & { rejection?: unknown } diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 9c30bf4311dd..dffe87f3af09 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -11,6 +11,8 @@ import { journalDispatchRowNewlyRejects } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionCommandTurnIdentity } from '../../../shared/structured-agent-session-command-turn-identity' import type { JournalRow } from './journal-row-schema' import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { @@ -58,6 +60,7 @@ export function settleOwedQueuedMessages( consumedRef, reason: submission?.reason ?? null, rejection: submission?.rejection, + commandTurnReported: commandTurnReported(input.state, consumedRef), now: input.now }) settled += changed ? 1 : 0 @@ -123,7 +126,15 @@ export function settleQueuedMessagesForRow( consumedRef: row.clientMessageId, reason: row.reason, rejection: row.rejection, + commandTurnReported: commandTurnReported(input.state, row.clientMessageId), now: input.now }) return changed + (settled ? 1 : 0) } + +/** The submission's command turn is in the journal: its own row reports the refusal. */ +function commandTurnReported(state: JournalReducerState, clientMessageId: string): boolean { + return state.items.has( + agentJournalItemKey(structuredAgentSessionCommandTurnIdentity(clientMessageId)) + ) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 852ebc5d7ddf..aaaef6d4fa65 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -5,7 +5,8 @@ // submission's append. Until then it lives here, `session_id`-keyed so it // survives epoch rollover and replacement (`journal-row-table.ts` deletes only // `journal_rows`). After a refusal its text survives as a `returned` row a -// rewind cannot delete; after a withdrawal it waits again. +// rewind cannot delete — a command refused in its own turn is spent instead, its +// turn's row saying why; after a withdrawal it waits again. import type Database from '../../sqlite/sync-database' import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -226,8 +227,7 @@ export function withdrawQueuedMessages( * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused * again still settles while a late duplicate of an earlier refusal matches - * nothing. A draft back to waiting keeps its position and carries no refusal; its spent - * submissions stay findable by their `queuedMessageId` link. + * nothing. A withdrawal keeps its position; a command refused in its own turn is spent. */ export function settleRejectedQueuedMessage( db: Database.Database, @@ -236,10 +236,19 @@ export function settleRejectedQueuedMessage( consumedRef: string reason: string | null rejection: UnreadAgentSessionFailureFact | undefined + /** The refused submission's command turn exists, so its own row says why. */ + commandTurnReported: boolean now: number } ): boolean { const settlement = rejectedDraftSettlement(input) + if ( + settlement.state === 'returned' && + input.commandTurnReported && + spendRefusedCommandCard(db, input) + ) { + return true + } const changed = settlement.state === 'waiting' ? db @@ -266,6 +275,26 @@ export function settleRejectedQueuedMessage( return Number(changed.changes ?? 0) > 0 } +/** + * A command card refused in its own turn is spent, not returned: that turn's row says why, once, + * and a returned card would hold every card behind it. Refused before any turn (a failed start), + * it is returned like any card; a restart or a close leaves it waiting under the queue's pause. + * False when the dispatched card is not a command. + */ +function spendRefusedCommandCard( + db: Database.Database, + input: { sessionId: string; consumedRef: string; now: number } +): boolean { + const spent = db + .prepare( + `UPDATE queued_messages SET state = 'withdrawn', settled_at = ? + WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ? + AND json_extract(body_json, '$.command') IS NOT NULL` + ) + .run(input.now, input.sessionId, input.consumedRef) + return Number(spent.changes ?? 0) > 0 +} + /** Replay receipts: every row a given caller-scoped operation settled. */ export function queuedMessagesSettledByOp( db: Database.Database, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index 5a1bbeab5120..30810b9b9f3e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -17,7 +17,6 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { - agentJournalItemKey, agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -43,6 +42,10 @@ import type { } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { conversationCommandBlocked } from './structured-conversation-command-admission' +import { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity' +import { refuseQueuedCommand } from './structured-agent-session-queued-command-refusal' + +export { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity' export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' @@ -87,24 +90,6 @@ export function structuredAgentSessionAwaitedCommand( : undefined } -/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the - * host's Stop and delivery gate tell a command's turn from any other. */ -export function structuredAgentSessionCommandTurn(clientMessageId: string): { - identity: AgentJournalItemIdentity - itemId: string - turnId: string - /** The command's one result row, inside its turn. */ - resultIdentity: AgentJournalItemIdentity -} { - const identity = { provider: 'orca' as const, clientMessageId: `command-turn:${clientMessageId}` } - return { - identity, - itemId: agentJournalItemKey(identity), - turnId: `compact:${clientMessageId}`, - resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` } - } -} - /** Whether the journal's running turn is a command's, which takes no input while it runs. */ export function structuredAgentSessionCommandRunning( journal: Pick @@ -171,34 +156,30 @@ export async function handOverStructuredAgentSessionCommand( // Provider frames already received decide whether a turn is running: each landed at its call. const blocked = commandBlocked(ctx, body) if (blocked) { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'rejected', - ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }), - fence: ctx.fence - }) + const refused = { + state: 'rejected' as const, + ...agentSessionFailureWords(blocked, { + ...ctx.failureTextContext, + command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, + surface: 'rejection' + }) + } + if (submission.queuedMessageId === undefined) { + // The command RPC that sent it is still waiting, and its answer says why. + await ctx.journal.resolveDispatch({ clientMessageId, ...refused, fence: ctx.fence }) + return + } + // A queued card's refusal has nobody waiting on it: its turn's one row says why. + await refuseQueuedCommand( + ctx, + submission, + blocked, + refused, + STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + ) return } - const turn = structuredAgentSessionCommandTurn(clientMessageId) - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'pending', - fence: ctx.fence, - turnScope: ctx.journal.liveTurnScope() - }) - const startedAt = ctx.now() - const running = agentJournalTurnBody({ - turnId: turn.turnId, - state: 'running', - userItemId: agentJournalSubmissionKey(clientMessageId), - requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission), - startedAt - }) - await ctx.journal.appendItem(turn.identity, running, { - fence: ctx.fence, - observedAt: startedAt, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) + const { turn, running } = await openCommandTurn(ctx, submission) let admission: AgentSessionCommandAdmission try { admission = await ctx.adapter.compact!({ @@ -228,6 +209,35 @@ export async function handOverStructuredAgentSessionCommand( } } +/** Hands the command over and opens its own turn. */ +async function openCommandTurn( + ctx: StructuredAgentSessionCommandHandoverContext, + submission: AgentJournalSubmission +) { + const { clientMessageId } = submission + const turn = structuredAgentSessionCommandTurn(clientMessageId) + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + const startedAt = ctx.now() + const running = agentJournalTurnBody({ + turnId: turn.turnId, + state: 'running', + userItemId: agentJournalSubmissionKey(clientMessageId), + requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission), + startedAt + }) + await ctx.journal.appendItem(turn.identity, running, { + fence: ctx.fence, + observedAt: startedAt, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + return { turn, running } +} + /** Where the turn a handed-over submission runs in starts counting: its handover, so time spent * held behind a command or a start is not counted as the agent's work. */ export function structuredAgentSessionHandoverOrigin( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 4f30c3f1f00d..f9297723804f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -20,6 +20,7 @@ import type { AgentSessionMutationEnvelope, AgentSessionOptionResult, AgentSessionPromptResult, + AgentSessionQueuedSendReceipt, AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' @@ -167,13 +168,17 @@ export function sendPlan(params: { export type ConversationCommandAcceptance = | { clientMessageId: string } + /** Held as a card, keyed by the operation id, behind work in flight. */ + | { queued: AgentSessionQueuedSendReceipt } /** What an older build's run of this operation recorded. */ | { recorded: AgentSessionConversationCommandResult } /** `/compact` accepted like a send: one submission, keyed by the operation id, that the delivery - * loop carries out as the command's own turn. */ + * loop carries out as the command's own turn — or, asked with `delivery`, a card while the + * agent works, which the queue's drain turns into that submission. */ export function conversationCommandPlan(params: { envelope: AgentSessionMutationEnvelope + delivery?: 'queue-if-active' priorRecord: () => AgentSessionConversationCommandResult | null }): MutationPlan { const clientMessageId = params.envelope.clientOperationId @@ -181,7 +186,10 @@ export function conversationCommandPlan(params: { method: 'agentSession.conversationCommand', conversationWrite: true, settlesWithWrite: true, - fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, + fields: { + command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, + ...(params.delivery ? { delivery: params.delivery } : {}) + }, recoverUnknownFromDurableState: true, run: async (ctx) => { const sent = await performSend(ctx, { @@ -198,6 +206,17 @@ export function conversationCommandPlan(params: { if (outcome.status === 'succeeded' && outcome.conversationCommand) { return { recorded: outcome.conversationCommand } } + // A card answers from itself until drained, then from the submission it became; only a + // command that asked to wait can have one, as for a send. + const queued = + params.delivery === 'queue-if-active' + ? queuedSendAnswer(ctx.journal, clientMessageId) + : null + if (queued) { + return 'queued' in queued + ? { queued: queued.queued } + : { clientMessageId: queued.submission.clientMessageId } + } if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { return { clientMessageId } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command-refusal.ts new file mode 100644 index 000000000000..3546c0d9c2be --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command-refusal.ts @@ -0,0 +1,69 @@ +// A queued command card's refusal at hand-over: nobody waits on it, so its own turn says why. + +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity' +import type { StructuredAgentSessionCommandHandoverContext } from './structured-agent-session-command-turn' + +/** + * A queued command refused before it was handed over. Nothing reached the provider, so no hand-off + * is recorded: the ended turn and its row land in one batch, then the submission is rejected while + * still queued. A crash between leaves a queued submission, which a restart returns to waiting; + * never a card gone with no row, nor a command that may have run. + */ +export async function refuseQueuedCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + submission: AgentJournalSubmission, + fact: SubmissionRejectionFact, + refused: { state: 'rejected' } & AgentJournalDispatchRejection, + command: AgentSessionConversationCommand +): Promise { + const { clientMessageId } = submission + const turn = structuredAgentSessionCommandTurn(clientMessageId) + const now = ctx.now() + await ctx.journal.appendLifecycleBatch({ + settlementId: `command-settled:${clientMessageId}`, + fence: ctx.fence, + mutations: [ + { + kind: 'item', + identity: turn.identity, + body: agentJournalTurnBody({ + turnId: turn.turnId, + state: 'completed', + outcome: 'failure', + userItemId: agentJournalSubmissionKey(clientMessageId), + requestedAt: submission.submittedAt, + startedAt: now, + completedAt: now + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: turn.resultIdentity, + body: { + kind: 'status', + ...agentSessionFailureWords(fact, { + ...ctx.failureTextContext, + command, + surface: 'row' + }), + tone: 'error' + }, + turnScope: { kind: 'turn', turnItemId: turn.itemId } + } + ] + }) + await ctx.journal.resolveDispatch({ clientMessageId, ...refused, fence: ctx.fence }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts new file mode 100644 index 000000000000..f619112a2f31 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts @@ -0,0 +1,549 @@ +// A /compact asked to wait (`delivery`) while the agent works: held as a card like a queued +// send, answered at once, run when the queue drains it. Without the opt-in, today's refusal. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + QUEUED_MESSAGES_PERSON_RESERVE_BYTES, + QUEUED_MESSAGES_PUBLISHED_MAX_BYTES +} from './structured-agent-session-queued-published-bytes' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +function compact( + delivery?: 'queue-if-active', + clientOperationId = hostTestOperationId(), + options?: { internal?: true } +) { + const fields = { command: 'compact' as const, ...(delivery ? { delivery } : {}) } + return { + id: clientOperationId, + result: rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', clientOperationId), + ...fields, + ...(options?.internal ? {} : { userSend: true as const }) + }) + } +} + +function clear() { + const fields = { command: 'clear' as const } + return rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) +} + +async function queuedCompact(): Promise { + const { id, result } = compact('queue-if-active') + expect(await result).toMatchObject({ + ok: true, + value: { command: 'compact', state: 'completed', queued: { messageId: id, state: 'waiting' } } + }) + return id +} + +function prompt(state: 'pending' | 'resolved') { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + return journal.appendItem( + { provider: 'orca', clientMessageId: 'prompt-1' }, + { + kind: 'approval', + title: 'Allow the tool?', + detail: null, + options: [], + resolution: + state === 'pending' + ? { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null } + : { state, selectedOptionId: 'allow', resolvedBy: 'client-1', resolvedAt: 1 } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +const BACKGROUND_TASK: AgentChildWorkView = { + id: 'child-dev', + providerId: 'task-dev', + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: 'spawn-dev', generation: 1 } +} + +const settleMs = () => new Promise((resolve) => setTimeout(resolve, 150)) + +describe('a /compact that waits in line', () => { + it("uses the person's reserve when background cards fill their bound", async () => { + await rig.workingSend() + const backgroundRoom = + QUEUED_MESSAGES_PUBLISHED_MAX_BYTES - QUEUED_MESSAGES_PERSON_RESERVE_BYTES + const overhead = Buffer.byteLength(JSON.stringify(hostTestMessage('')), 'utf8') + const background = rig.send('x'.repeat(backgroundRoom - overhead), 'queue-if-active', { + internal: true + }) + expect(await background.result).toMatchObject({ + ok: true, + value: { queued: { messageId: background.id, state: 'waiting' } } + }) + expect( + await compact('queue-if-active', hostTestOperationId(), { internal: true }).result + ).toMatchObject({ + ok: false, + refusal: { details: { reason: 'queueTooLarge' } } + }) + const compactId = await queuedCompact() + expect(await rig.drafts()).toEqual([ + { messageId: background.id, state: 'waiting' }, + { messageId: compactId, state: 'waiting' } + ]) + expect(rig.compact).not.toHaveBeenCalled() + }) + + it('behind an unanswered message: a card at once, run once that message is answered', async () => { + const working = await rig.workingSend() + const compactId = await queuedCompact() + expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }]) + await settleMs() + expect(rig.compact).not.toHaveBeenCalled() + await rig.settleAccepted(working, 'a') + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + expect((await rig.handoff(compactId))?.handedOverAt).toBeDefined() + expect(await rig.drafts()).toEqual([]) + }) + + it('behind a running command turn: waits for that turn to end', async () => { + expect(await compact().result).toMatchObject({ ok: true, value: { state: 'completed' } }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + const second = await queuedCompact() + await settleMs() + expect(rig.compact).toHaveBeenCalledOnce() + rig.finishCompact() + await eventually(() => expect(rig.compact).toHaveBeenCalledTimes(2)) + expect((await rig.handoff(second))?.handedOverAt).toBeDefined() + }) + + it('behind a pending question or approval: waits for its answer', async () => { + await prompt('pending') + const compactId = await queuedCompact() + await settleMs() + expect(rig.compact).not.toHaveBeenCalled() + await prompt('resolved') + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + expect(await rig.handoff(compactId)).toBeDefined() + }) + + it('behind a message the agent refuses: still runs, nothing strands', async () => { + const working = await rig.workingSend() + await queuedCompact() + await rig.settleRejected(working, 'provider refused this payload') + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + }) + + it('a resent id answers from its card, then from the submission it became; one run', async () => { + const working = await rig.workingSend() + const compactId = await queuedCompact() + expect(await compact('queue-if-active', compactId).result).toMatchObject({ + ok: true, + value: { queued: { messageId: compactId, state: 'waiting' } } + }) + expect(await rig.drafts()).toHaveLength(1) + await rig.settleAccepted(working, 'a') + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + const resent = await compact('queue-if-active', compactId).result + expect(resent).toMatchObject({ ok: true, value: { command: 'compact', state: 'completed' } }) + expect(resent.ok && resent.value.queued).toBeFalsy() + await settleMs() + expect(rig.compact).toHaveBeenCalledOnce() + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === compactId + ) + ).toHaveLength(1) + }) + + it('never steers: Send-now on its card mid-turn is refused and it keeps waiting', async () => { + const working = await rig.workingSend() + const compactId = await queuedCompact() + expect(await rig.sendNow(compactId)).toMatchObject({ + ok: false, + refusal: { message: "A command can't be sent while the agent is working." } + }) + expect(await rig.handoff(compactId)).toBeUndefined() + await rig.settleAccepted(working, 'a') + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + }) + + it('a later send waits behind it, in the order sent', async () => { + const working = await rig.workingSend() + const compactId = await queuedCompact() + const later = await rig.send('sent after the compact', 'queue-if-active').result + if (!later.ok || !('queued' in later.value)) { + throw new Error('expected a queued receipt') + } + const laterId = later.value.queued.messageId + await rig.settleAccepted(working, 'a') + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + // The compaction's turn runs, so the later card still waits. + await settleMs() + expect(await rig.handoff(laterId)).toBeUndefined() + rig.finishCompact() + await eventually(async () => expect(await rig.handoff(laterId)).toBeDefined()) + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.flatMap((entry) => + entry.queuedMessageId ? [entry.queuedMessageId] : [] + ) + ).toEqual([compactId, laterId]) + }) + + it('at rest, runs at once as before', async () => { + const { result } = compact('queue-if-active') + const answer = await result + expect(answer).toMatchObject({ ok: true, value: { command: 'compact', state: 'completed' } }) + expect(answer.ok && answer.value.queued).toBeFalsy() + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + expect(await rig.drafts()).toEqual([]) + }) + + it('without the opt-in (an older client), is refused while a message is unanswered, as today', async () => { + await rig.workingSend() + expect(await compact().result).toMatchObject({ + ok: false, + refusal: { details: { reason: 'messagesUnsettled' } } + }) + expect(await rig.drafts()).toEqual([]) + }) + + it('Delete takes it back: it never runs', async () => { + const working = await rig.workingSend() + const compactId = await queuedCompact() + expect(await rig.deleteQueued(compactId)).toMatchObject({ ok: true, value: { deleted: true } }) + await rig.settleAccepted(working, 'a') + await settleMs() + expect(rig.compact).not.toHaveBeenCalled() + expect(await rig.drafts()).toEqual([]) + }) + + it('Stop pauses it with the queue, and Resume runs it', async () => { + const working = await rig.workingSend() + const compactId = await queuedCompact() + await rig.stop() + await rig.settleAccepted(working, 'a') + await settleMs() + expect(rig.compact).not.toHaveBeenCalled() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + }) + + /** The rows that report a failure, and the cards still listed. */ + async function failureRowsAndCards() { + const snapshot = await rig.host.journalSnapshot(SESSION) + return { + failures: snapshot.items.filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ), + cards: await rig.drafts() + } + } + + it('refused by the agent: spent, said once in its turn, and the next card still runs', async () => { + // Every compaction is refused, as on a short chat: a returned card would loop or block. + rig.compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'Not enough messages to compact.', audience: 'person' } + }), + { surface: 'rejection' } + ) + }) + const working = await rig.workingSend() + await queuedCompact() + const later = await rig.send('sent after the compact', 'queue-if-active').result + if (!later.ok || !('queued' in later.value)) { + throw new Error('expected a queued receipt') + } + await rig.settleAccepted(working, 'a') + const laterId = later.value.queued.messageId + await eventually(async () => expect(await rig.handoff(laterId)).toBeDefined()) + const { failures, cards } = await failureRowsAndCards() + expect(failures).toHaveLength(1) + expect(JSON.stringify(failures[0]!.body)).toContain('Not enough messages to compact.') + expect(cards).toEqual([]) + expect(rig.compact).toHaveBeenCalledOnce() + }) + + it('refused before it starts (background tasks): one row says so, and the next card runs', async () => { + let tasks: AgentChildWorkView[] = [] + Object.assign(rig.host.deps, { + statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => tasks } + }) + const working = await rig.workingSend() + const compactId = await queuedCompact() + const later = await rig.send('sent after the compact', 'queue-if-active').result + if (!later.ok || !('queued' in later.value)) { + throw new Error('expected a queued receipt') + } + tasks = [BACKGROUND_TASK] + await rig.settleAccepted(working, 'a') + const laterId = later.value.queued.messageId + await eventually(async () => expect(await rig.handoff(laterId)).toBeDefined()) + expect(rig.compact).not.toHaveBeenCalled() + // Refused while still queued: no hand-off was recorded for a command that never left. + const refused = await rig.handoff(compactId) + expect(refused).toMatchObject({ dispatchState: 'rejected' }) + expect(refused?.handedOverAt).toBeUndefined() + const { failures, cards } = await failureRowsAndCards() + expect(failures).toHaveLength(1) + // The reason, as the direct path says it, not a bare "try it again". + expect(failures[0]!.body).toMatchObject({ + text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.' + }) + expect(cards).toEqual([]) + }) + + it('refused before it starts, a failed rejection write never drops the card without its row', async () => { + let tasks: AgentChildWorkView[] = [] + Object.assign(rig.host.deps, { + statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => tasks } + }) + const working = await rig.workingSend() + const compactId = await queuedCompact() + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const resolve = journal.resolveDispatch.bind(journal) + const failed = vi.fn() + vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (input) => { + if (input.state === 'rejected' && !failed.mock.calls.length) { + failed() + throw new Error('disk full') + } + return resolve(input) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + tasks = [BACKGROUND_TASK] + await rig.settleAccepted(working, 'a') + await eventually(() => expect(failed).toHaveBeenCalledOnce()) + // The refusal's row landed before the write that failed, so the card never left without it; + // the loop then reports its own fault, and nothing was ever handed over. + const { failures } = await failureRowsAndCards() + expect(failures.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.' + }) + ) + expect((await rig.handoff(compactId))?.handedOverAt).toBeUndefined() + expect(rig.compact).not.toHaveBeenCalled() + }) + + it('a failed agent start returns its card like any card, never spends it', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true }) + const working = await rig.workingSend() + const compactId = await queuedCompact() + await rig.stop() + await rig.settleAccepted(working, 'a') + // The chat's agent went away; the next start fails. + await rig.restartHostProcess() + rig.failNextStart(new Error('the agent could not start')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'returned' }]) + ) + expect(rig.compact).not.toHaveBeenCalled() + }) + + it('Send on its card works whenever the agent is idle, as after a paused queue', async () => { + const working = await rig.workingSend() + const compactId = await queuedCompact() + await rig.stop() + await rig.settleAccepted(working, 'a') + expect(await rig.sendNow(compactId)).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: compactId } } + }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + }) + + /** A command card consumed while the agent starts, and never handed over: then Orca crashes. */ + async function consumedThenCrashed(send: 'drain' | 'sendNow'): Promise { + await rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true }) + const working = await rig.workingSend() + const compactId = await queuedCompact() + await rig.stop() + await rig.settleAccepted(working, 'a') + // No child now, and the next start stays in its spawn: the hand-off stays queued. + await rig.host.close(SESSION, 'evict') + const release = rig.holdNextStart() + const startsBefore = rig.starts.mock.calls.length + if (send === 'drain') { + expect(await rig.resume()).toMatchObject({ ok: true }) + } else { + expect(await rig.sendNow(compactId)).toMatchObject({ ok: true }) + } + await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore)) + rig.crashRestartHostProcess() + release() + return compactId + } + + it('cut short by a restart after the queue sent it: waits again under the restart, never spent', async () => { + const compactId = await consumedThenCrashed('drain') + // The queue's own hand-off is not the person's, so it waits as any queued card does after a + // restart: held unshown, never sent by itself. + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }]) + ) + expect(await rig.queuePause()).toBeNull() + expect(rig.compact).not.toHaveBeenCalled() + }) + + it('cut short by a restart after the person sent it: waits again, never spent', async () => { + const compactId = await consumedThenCrashed('sendNow') + expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }]) + expect(await rig.queuePause()).toBeNull() + const card = rig.host + .collaboratorsForTests() + .sessions.get(SESSION) + ?.journal.queuedMessages.get(compactId) + expect(card).toMatchObject({ + holdReason: null, + body: { command: { name: 'compact' } } + }) + expect(rig.compact).not.toHaveBeenCalled() + }) + + it('a kept message stays ahead of a waiting /compact after the next turn releases both', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true }) + const working = await rig.workingSend() + const compactId = await queuedCompact() + await rig.stop() + await rig.settleAccepted(working, 'a') + await rig.host.close(SESSION, 'evict') + const release = rig.holdNextStart() + const startsBefore = rig.starts.mock.calls.length + const kept = rig.send('kept message') + await kept.result + await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore)) + // Finish the old host before reopening so its delayed start cannot keep writing. + const restarted = rig.quitRestartHostProcess() + release() + await restarted + + expect(await rig.drafts()).toEqual([ + { messageId: kept.id, state: 'waiting' }, + { messageId: compactId, state: 'waiting' } + ]) + expect(rig.compact).not.toHaveBeenCalled() + const next = rig.send('next turn', 'queue-if-active') + expect(await next.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(async () => expect((await rig.handoff(kept.id))?.handedOverAt).toBeDefined()) + expect(rig.dispatch.mock.calls.at(-1)?.[0].body.blocks).toEqual([ + { type: 'text', text: 'kept message' } + ]) + expect(rig.compact).not.toHaveBeenCalled() + await rig.settleAccepted(await rig.handoffId(kept.id), 'kept') + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + expect(await rig.drafts()).toEqual([]) + }) + + it('a direct /compact (no opt-in, as on a host without the queue) cut short by a restart is never a card', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true }) + await rig.workingSend() + await rig.host.close(SESSION, 'evict') + const release = rig.holdNextStart() + const startsBefore = rig.starts.mock.calls.length + const { id } = compact() + await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore)) + rig.crashRestartHostProcess() + release() + // A command in flight is not resumed: the person runs it again. No card, kept or otherwise. + expect(await rig.drafts()).toEqual([]) + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected' }) + }) + + it('a /clear drops a waiting command card instead of carrying it to the new chat', async () => { + const working = await rig.workingSend() + await queuedCompact() + const draft = await rig.send('carried draft', 'queue-if-active').result + if (!draft.ok || !('queued' in draft.value)) { + throw new Error('expected a queued receipt') + } + await rig.stop() + await rig.settleAccepted(working, 'a') + const cleared = await clear() + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.drafts()).toEqual([]) + expect(await rig.drafts(replacementId)).toEqual([ + { messageId: draft.value.queued.messageId, state: 'waiting' } + ]) + expect(rig.compact).not.toHaveBeenCalled() + }) + + it('a /clear with only a command card waiting opens no replacement conversation for it', async () => { + const working = await rig.workingSend() + await queuedCompact() + await rig.stop() + await rig.settleAccepted(working, 'a') + const cleared = await clear() + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.drafts()).toEqual([]) + expect(rig.host.collaboratorsForTests().sessions.has(replacementId)).toBe(false) + }) +}) + +it('only a /compact may ask to wait; a /clear sent asking to is refused, never queued', async () => { + const base = { envelope: rig.envelope({}, 'agentSession.conversationCommand', 'op-schema') } + const parse = (fields: Record) => + ConversationCommandParams.safeParse({ ...base, ...fields }).success + expect(parse({ command: 'compact', delivery: 'queue-if-active' })).toBe(true) + expect(parse({ command: 'compact', delivery: 'now' })).toBe(false) + await rig.workingSend() + const fields = { command: 'clear' as const, delivery: 'queue-if-active' as const } + expect( + await rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: false }) + expect(await rig.drafts()).toEqual([]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index ccabd39503e6..83738cf91a4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -36,6 +36,7 @@ export function createQueuedRigProvider( // Every start the host asks for; one `holdNextStart` holds stays in its spawn until released. const starts: Mock<() => void> = vi.fn() let startHold: Promise | null = null + let startFailure: Error | null = null // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. const compact: Mock> = vi.fn(async () => ({ state: 'accepted' as const, @@ -55,6 +56,11 @@ export function createQueuedRigProvider( const hold = startHold startHold = null await hold + const failure = startFailure + startFailure = null + if (failure) { + throw failure + } events = sink const resumes = options.restartable === true && @@ -112,6 +118,11 @@ export function createQueuedRigProvider( return () => release() } + /** Fails the next start in its spawn, as an agent that cannot start does. */ + function failNextStart(error: Error): void { + startFailure = error + } + /** The event sink the provider writes through. */ function providerEvents(): StructuredAgentSessionEventSink { if (!events) { @@ -125,6 +136,7 @@ export function createQueuedRigProvider( dispatch, starts, holdNextStart, + failNextStart, compact, cancelTurn, closeSession, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 90f2a96acba3..fb4cf151174d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -285,6 +285,7 @@ export async function createQueuedMessageTestRig( compact, starts: provider.starts, holdNextStart: provider.holdNextStart, + failNextStart: provider.failNextStart, finishCompact: provider.finishCompact, providerEvents: provider.providerEvents, envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 23a8af3582a6..249396d38b5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -78,8 +78,8 @@ function oldestActionableQueuedMessage( * hold, or an actionable backlog, queues the send as a draft. * drain step: any hold returns early; whatever clears it publishes or * commits, which re-derives. - * Send-now: overrides only `working` (plus FIFO order and the stored hold); - * `blocked` and `prompt` refuse readably. + * Send-now: overrides only `working` (plus FIFO order and the stored hold), + * never for a command card; `blocked` and `prompt` refuse readably. * * `blocked` is whatever refuses any send (an uncertain rewind, a cleared source); * the rest are waits. A /compact is a queued message and then a turn, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index e20bd60b9eba..f57723ed61c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -82,7 +82,8 @@ export async function withdrawQueuedMessagesForOperation( * came from, which IS that pause, so the drain never sees a carried card unpaused * and no pause outlives the cards. Runs after the clear commits, opening the * replacement's conversation only when there are drafts to carry; the source - * rows are then tombstoned. Bookkeeping around the clear: a failure, or a crash + * rows are then tombstoned. A command card (a waiting /compact) is tombstoned + * without a copy. Bookkeeping around the clear: a failure, or a crash * before the carry, leaves the cards on the superseded source — whose * supersession fence already blocks the drain — reported, never gating the * clear. A crash between the copy and the tombstone leaves both, which the @@ -102,21 +103,25 @@ export async function carryQueuedMessagesToClearReplacement( if (rows.length === 0) { return } - const replacement = await input.openReplacementJournal() - if (!replacement) { - throw new Error('the replacement journal is not open') - } - for (const row of rows) { - // A returned card carries over as a plain waiting draft — its refusal - // belonged to the source's submissions. The fingerprint is re-scoped to the - // replacement, or its echo could never alias the sent bubble. - await replacement.queuedMessages.insert({ - messageId: row.messageId, - body: row.body, - fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), - hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId - }) + // A command belongs to the discarded context, so it is withdrawn instead of carried. + const carried = rows.filter((row) => !row.body.command) + if (carried.length > 0) { + const replacement = await input.openReplacementJournal() + if (!replacement) { + throw new Error('the replacement journal is not open') + } + for (const row of carried) { + // A returned card carries over as a plain waiting draft — its refusal + // belonged to the source's submissions. The fingerprint is re-scoped to the + // replacement, or its echo could never alias the sent bubble. + await replacement.queuedMessages.insert({ + messageId: row.messageId, + body: row.body, + fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), + hostInstance: structuredAgentSessionHostInstance(), + carriedFrom: ctx.sessionId + }) + } } await withdrawQueuedMessagesForOperation(ctx.journal, { sessionId: ctx.sessionId, @@ -214,6 +219,11 @@ export function sendQueuedStructuredAgentMessage( ? { ok: true, value: { clientMessageId: submission.clientMessageId, submission } } : invalid('This queued message was already sent.') } + // A command never steers: handed over mid-turn it would only be refused. Clients offer its + // Send only while the agent is idle; this answers an older one that offers it mid-turn. + if (hold === 'working' && row.body.command) { + return invalid("A command can't be sent while the agent is working.") + } const submissionId = operationId try { await ctx.journal.appendSubmission( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 3cd2417eb72e..a5991fa2f1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -187,6 +187,8 @@ async function params(itemId: string, epoch?: string) { } } +const LATE = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'late', ordinal: 0 } + describe('host rewind', () => { it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => { await expectRawStopNoteRewindRecovery({ host, store, rewind }) @@ -323,6 +325,41 @@ describe('host rewind', () => { }) expect(rewind).not.toHaveBeenCalled() }) + it('is busy exactly while the chat shows a sent message unanswered', async () => { + const target = await seed() + const body = hostTestMessage('unanswered') + const clientOperationId = hostTestOperationId() + // Accepted: the wait below sees its submission settle in doubt. + await host.send(caller, { + body, + envelope: { + ...(await params(target)).envelope, + clientOperationId, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + // The adapter's reply is lost: a live doubt, which the chat shows as working. + const sent = async () => + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + ) + await vi.waitFor(async () => expect((await sent())?.dispatchState).toBe('unknown')) + expect(await host.rewind(caller, await params(target))).toMatchObject({ + ok: false, + refusal: { rewindReason: 'busy' } + }) + await host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId: clientOperationId, + providerIdentity: LATE + }) + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) + expect(rewind).toHaveBeenCalledOnce() + }) it('refuses stale epochs and targets from another provider', async () => { const target = await seed() expect(await host.rewind(caller, await params(target, 'old-epoch'))).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 5c2ff3662437..94fe8f4120b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -6,6 +6,8 @@ import { type ConversationCommandAdmissionContext } from './structured-conversation-command-admission' import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' const TARGETED: AgentSessionBackgroundTaskStops = { supportsTaskStop: true, supportsStopAll: true } const UNTARGETED: AgentSessionBackgroundTaskStops = { @@ -192,3 +194,66 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => expect(conversationCommandBlocked(ctx, RECORD, [], 'handover')).toBeNull() }) }) + +describe('conversationCommandBlocked on unsettled messages', () => { + // One send per state the Working indicator and admission could read differently. + const SENDS: Record> = { + queued: { dispatchState: 'pending', handoverRecorded: true }, + handedOver: { dispatchState: 'pending', handoverRecorded: true, handedOverAt: 5 }, + handedOverByEarlierChild: { + dispatchState: 'pending', + handoverRecorded: true, + handedOverAt: 5, + fence: 0 + }, + liveUnknown: { dispatchState: 'unknown' }, + recoveredUnknown: { dispatchState: 'unknown', recovered: true }, + restartedUnknown: { dispatchState: 'unknown', reason: 'host_restarted_before_acknowledgement' }, + accepted: { dispatchState: 'accepted' }, + rejected: { dispatchState: 'rejected' } + } + + function admissionOver(send: string, admission?: 'at-rest' | 'handover') { + const ctx = contextWith(undefined) + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: send, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + ...SENDS[send] + } + ] + ctx.journal.submissions = () => submissions + const refusal = conversationCommandBlocked(ctx, RECORD, [], admission) + return { + blocked: refusal?.details && 'reason' in refusal.details ? refusal.details.reason : null, + working: hasUnansweredStructuredAgentSessionDispatch(submissions, ctx.fence) + } + } + + it.each(Object.keys(SENDS))( + 'refuses on %s exactly when the chat shows the agent working', + (send) => { + const { blocked, working } = admissionOver(send) + expect(blocked).toBe(working ? 'messagesUnsettled' : null) + expect(admissionOver(send, 'at-rest').blocked).toBe(blocked) + } + ) + + it('does not refuse what the chat no longer shows as working', () => { + // An earlier child's hand-off and a restart's doubt: the chat reads idle, so nothing refuses. + expect(admissionOver('handedOverByEarlierChild').blocked).toBeNull() + expect(admissionOver('restartedUnknown').blocked).toBeNull() + }) + + it('at handover, a queued message is one behind the command; a handed-over one still refuses', () => { + expect(admissionOver('queued', 'handover').blocked).toBeNull() + expect(admissionOver('handedOver', 'handover').blocked).toBe('messagesUnsettled') + expect(admissionOver('liveUnknown', 'handover').blocked).toBe('messagesUnsettled') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index dfb4c48f1a9c..d20dcd45e7a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -8,6 +8,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { @@ -114,14 +115,16 @@ export function conversationCommandBlocked( : 'Wait for background tasks to finish before using this command.' ) } + // The Working indicator's own rule, so "unsettled" is exactly what the chat shows as working. + // At handover the queued messages are those behind this command, waiting for it. if ( - ctx.journal.submissions().some( - (entry) => - (entry.dispatchState === 'pending' && - !(admission === 'handover' && isQueuedAgentJournalSubmission(entry))) || - // Doubt left by an earlier child is not this one's work in flight. - (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) - ) + ctx.journal + .submissions() + .some( + (entry) => + !(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) && + isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence) + ) ) { return blocked( 'messagesUnsettled', diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 764b569d8ff5..95bdad666e0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -39,6 +39,10 @@ export function conversationCommandFailure( export type ConversationCommandParams = { envelope: AgentSessionMutationEnvelope command: AgentSessionConversationCommand + /** /compact only: wait as a card while the agent works, as a queued send does. */ + delivery?: 'queue-if-active' + /** Host-local, set by the client-facing command RPC as for an ordinary send. */ + userSend?: true } export type ConversationReplacement = { sourceSessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index a5db53e5555c..ffaf2104b410 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -392,7 +392,12 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B } await expect(commanded).resolves.toMatchObject({ ok: true, - value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } + // The reason, said as its refusal is everywhere, not a bare "try it again". + value: { + state: 'completed', + error: "The agent is still working. Run /compact when it's done.", + failure: refused + } }) expect(compact).not.toHaveBeenCalled() expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() @@ -402,7 +407,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B ) ).toMatchObject({ dispatchState: 'rejected', - reason: "This command didn't run. Try it again.", + reason: "The agent is still working. Run /compact when it's done.", rejection: refused }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 98c04a2e8401..c12f3d413692 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -1,5 +1,6 @@ // `/compact` sent through the command RPC: accepted into the conversation as the user's message, -// and answered once the delivery loop hands it over. +// and answered once the delivery loop hands it over. Asked with `delivery` while the agent works, +// it waits as a card like a queued send, answered at once. import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { @@ -9,10 +10,7 @@ import { import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult -} from '../../../shared/agent-session-wire' +import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { mutateStructuredAgentSession, @@ -34,6 +32,8 @@ import { type ConversationCommandParams } from './structured-conversation-command' import { conversationCommandBlocked } from './structured-conversation-command-admission' +import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' +import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' /** * `/compact` from a client that asks through the command RPC: accepted into the conversation like @@ -68,6 +68,13 @@ export async function runStructuredCompaction( if ('recorded' in accepted.value) { return { ...accepted, value: accepted.value.recorded } } + if ('queued' in accepted.value) { + // The card is the one surface from here: a refusal in its own turn is said there, once. + return { + ...accepted, + value: { command: 'compact', state: 'completed', queued: accepted.value.queued } + } + } const settled = await host.waitForSendSettlement(sessionId, accepted.value.clientMessageId, { until: 'handed-over', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS @@ -123,10 +130,14 @@ function compactionReply( function acceptStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { envelope: AgentSessionMutationEnvelope }, + params: ConversationCommandParams, priorRecord: () => AgentSessionConversationCommandResult | null ): Promise> { - const plan = conversationCommandPlan({ envelope: params.envelope, priorRecord }) + const plan = conversationCommandPlan({ + envelope: params.envelope, + ...(params.delivery ? { delivery: params.delivery } : {}), + priorRecord + }) return mutateStructuredAgentSession( context, caller, @@ -134,6 +145,28 @@ function acceptStructuredConversationCommand( { ...plan, run: async (ctx) => { + // The queue's own accept rule decides first: whatever a queued send waits behind, the + // command waits behind too, so admission below never sees work in flight for it. + const queued = await maybeQueueStructuredAgentSessionSend(context, ctx, { + envelope: params.envelope, + body: structuredAgentSessionCompactBody(), + ...(params.userSend ? { userSend: params.userSend } : {}), + ...(params.delivery ? { delivery: params.delivery } : {}) + }) + if (queued && !queued.ok) { + return queued + } + if (queued) { + // A card already drained answers from the submission it became. + const { value } = queued + return { + ok: true, + value: + 'queued' in value + ? { queued: value.queued } + : { clientMessageId: value.submission.clientMessageId } + } + } const record = context.deps.store.getRecord(ctx.sessionId) const refusal = record && diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 7324ae7ffd2e..17dbdd49a0ae 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -118,7 +118,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ await ensureHostInstalled(ctx) const host = requireSessionHost(ctx, params.envelope.sessionId) await host.revealSession(params.envelope.sessionId) - const result = await host.conversationCommand(callerFor(ctx), params) + const result = await host.conversationCommand(callerFor(ctx), { ...params, userSend: true }) if (result.ok && result.value.command === 'clear' && result.value.replacementSessionId) { const replacement = host .conversationReplacements() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 8c09a70a2d1c..920e76d4c50c 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -59,10 +59,16 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null // 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." - ) + // Its Send shows only while the agent is idle; the words match what is on the card. + return card.waitsForAgent + ? translate( + 'components.native-chat.queuedMessages.pausedSendFailedWaiting', + "Couldn't send — press Send to retry once the agent finishes." + ) + : translate( + 'components.native-chat.queuedMessages.pausedSendFailed', + "Couldn't send — press Send to retry." + ) } return translate('components.native-chat.queuedMessages.paused', 'Paused') case 'behind-returned': @@ -75,6 +81,8 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null 'components.native-chat.queuedMessages.awaitingAnswerHold', 'Waiting for your answer' ) + case 'sending': + return translate('components.native-chat.messageSending', 'Sending…') case 'turn': case 'queue-paused': // Plainly queued; a paused queue's header row carries the why. @@ -162,67 +170,88 @@ export function NativeChatQueuedMessageCard({

) : null} - - - - - - - {sendNow.hint} - {showsSteerShortcut ? ( - - ) : null} - - - - - - - - - {translate('components.native-chat.queuedMessages.delete', 'Delete')} - - - - - - - - - - {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} - - {onTurnOffQueueing ? ( - - {translate( - 'components.native-chat.queuedMessages.turnOffQueueing', - 'Turn off queueing' + {/* Nothing acts on a send still on its way: the host holds no card for it yet. */} + {card.hold === 'sending' ? null : ( + <> + {/* A command never steers: its Send shows only while the agent is idle. */} + {card.waitsForAgent ? null : ( + + + + + + + {sendNow.hint} + {showsSteerShortcut ? ( + + ) : null} + + + + )} + + + + + + {translate('components.native-chat.queuedMessages.delete', 'Delete')} + + + + + + + + {/* A command's text is not a draft: edited, it would become a message. */} + {card.command ? null : ( + + + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} + )} - - ) : null} - - + {onTurnOffQueueing ? ( + + {translate( + 'components.native-chat.queuedMessages.turnOffQueueing', + 'Turn off queueing' + )} + + ) : null} + + + + )} ) } diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index b914b31196b4..b795eeb625f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -170,6 +170,65 @@ describe('NativeChatQueuedMessageList', () => { expect(owner.remove).toHaveBeenCalledWith('draft-2') }) + it('a command card never steers: Send only while the agent is idle', () => { + const owner = controller([ + card({ messageId: 'compact-1', text: '/compact', command: true, waitsForAgent: true }), + card({ messageId: 'compact-2', text: '/compact', command: true, hold: 'paused' }) + ]) + renderList(owner) + expect(screen.getAllByText('/compact')).toHaveLength(2) + expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(owner.steer).toHaveBeenCalledWith('compact-2') + expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2) + }) + + it('a send on its way reads Sending and offers nothing until the host holds it', () => { + renderList(controller([card({ messageId: 'sending-1', text: 'on its way', hold: 'sending' })])) + expect(screen.getByText('Sending…')).toBeTruthy() + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) + + it("a send-failed command card's caption names Send only when Send is there", () => { + const failed = { command: true as const, hold: 'paused' as const, pausedReason: 'send_failed' } + renderList( + controller([ + card({ messageId: 'working', text: '/compact', ...failed, waitsForAgent: true }), + card({ messageId: 'idle', text: '/compact', ...failed }) + ]) + ) + expect( + screen.getByText("Couldn't send — press Send to retry once the agent finishes.") + ).toBeTruthy() + expect(screen.getByText("Couldn't send — press Send to retry.")).toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Send' })).toHaveLength(1) + }) + + it('an idle command card waiting its turn reads Send, not Steer', () => { + renderList(controller([card({ messageId: 'compact-1', text: '/compact', command: true })])) + expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() + }) + + it('a command card offers no Edit: its text is not a draft', async () => { + renderList(controller([card({ messageId: 'compact-1', text: '/compact', command: true })])) + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + expect(await screen.findByRole('menuitem', { name: 'Turn off queueing' })).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: 'Edit message' })).toBeNull() + }) + + it("a paused command card's ways out are Delete and the queue's Resume", () => { + const owner = controller( + [card({ messageId: 'compact-1', text: '/compact', command: true, hold: 'queue-paused' })], + { reason: 'stopped' } + ) + renderList(owner) + fireEvent.click(screen.getByRole('button', { name: 'Resume' })) + expect(owner.resume).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(owner.remove).toHaveBeenCalledWith('compact-1') + }) + it("holds every card's Steer while a person's Stop ends the turn; Delete still works", () => { const owner = controller([card({ messageId: 'draft-1', position: 1 })]) render( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx new file mode 100644 index 000000000000..8d56bcd32330 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +const STILL_WORKING = "The agent is still working. Run /clear when it's done." +const BACKGROUND = + 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.' + +function renderPane() { + const pane = () => ( + + ) + const view = render(pane()) + // A fresh element: the mocked controller reads the chat's state only when the pane renders. + return { rerender: () => view.rerender(pane()) } +} + +function composerSays( + text: string | null, + refusedWhile?: StructuredAgentSessionCommandRefusalCause +) { + const onError = mocks.composerProps?.structuredTransport?.onError + if (typeof onError !== 'function') { + throw new Error('the composer has no transport') + } + act(() => onError(text, { refusedWhile })) +} + +it('a /clear refused while the agent works is said until the agent stops, then gone', () => { + mocks.turnId = 'turn-1' + const pane = renderPane() + composerSays(STILL_WORKING, 'working') + expect(screen.getAllByText(STILL_WORKING)).toHaveLength(1) + + mocks.turnId = null + pane.rerender() + expect(screen.queryByText(STILL_WORKING)).toBeNull() + // Gone, not hidden: the agent working again later is not what that press was refused for. + mocks.turnId = 'turn-2' + pane.rerender() + expect(screen.queryByText(STILL_WORKING)).toBeNull() +}) + +it('a /clear refused behind background tasks is said until the last one ends, then gone', () => { + mocks.monitoringBackgroundTasks = true + const pane = renderPane() + composerSays(BACKGROUND, 'background') + expect(screen.getAllByText(BACKGROUND)).toHaveLength(1) + + mocks.monitoringBackgroundTasks = false + pane.rerender() + expect(screen.queryByText(BACKGROUND)).toBeNull() +}) + +it('a line naming nothing the chat shows stays until the next send, as before', () => { + mocks.turnId = 'turn-1' + const pane = renderPane() + composerSays('Remove attachments before using a chat-session command.') + mocks.turnId = null + pane.rerender() + expect(screen.getByText('Remove attachments before using a chat-session command.')).toBeTruthy() + composerSays(null) + expect(screen.queryByText('Remove attachments before using a chat-session command.')).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx index e2e762a3ec0e..691e950593cd 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx @@ -24,6 +24,7 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard import { NativeChatStructuredSession } from './NativeChatStructuredSession' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' const SESSION_ID = 'notice-card-session' @@ -45,7 +46,7 @@ function renderPane(): void { ) } -function composerOnError(): (text: string | null, errorText?: string) => void { +function composerOnError(): NativeChatStructuredComposerTransport['onError'] { const onError = mocks.composerProps?.structuredTransport?.onError if (!onError) { throw new Error('composer transport has no onError') @@ -72,7 +73,9 @@ it("shows the chat's own error and a composer error together, where one used to it('keeps a send failure’s raw error apart and lets the user dismiss it', () => { renderPane() act(() => { - composerOnError()('Your message was not sent.', 'connect ECONNREFUSED /tmp/agent-host.sock') + composerOnError()('Your message was not sent.', { + errorText: 'connect ECONNREFUSED /tmp/agent-host.sock' + }) }) expect(screen.getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE') fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) 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 c6589c1e8a4a..cead60b30e21 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -10,7 +10,10 @@ import type { AgentSessionRefusalReference } from '../../../../shared/agent-sess import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' -import type { NativeChatLaunchSeed } from './native-chat-composer-types' +import type { + NativeChatLaunchSeed, + NativeChatStructuredComposerTransport +} from './native-chat-composer-types' import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest' import type { NativeChatFileLinkContext } from './native-chat-file-link' import type { NativeChatComposerNotice } from './native-chat-composer-notice' @@ -22,23 +25,11 @@ import type { SessionOptionSetResult, SessionOptionValue } from '../../../../shared/native-chat-session-options' +import { absent, nullable, widened } from './native-chat-mock-slot-types.test-support' type StopBackgroundTaskSpy = (sessionId: string, taskId?: string) => unknown -function nullable(): T | null { - return null -} - -function widened(value: T): T { - return value -} - -function absent(): T | undefined { - return undefined -} - -/** Stands in for the transcript: renders only each message's delivery notice, or the row's quiet - * "Sending…" while nothing has confirmed it. */ +/** Stands in for the transcript: renders each message's delivery notice or quiet "Sending…". */ export function DeliveryNoticesMock({ notices }: { @@ -142,7 +133,7 @@ export function createStructuredSessionMocks() { launchSeed?: NativeChatLaunchSeed structuredTransport?: Record & { queueResume?: QueueResumeMock - onError?: (text: string | null, errorText?: string) => void + onError?: NativeChatStructuredComposerTransport['onError'] } isWorking?: boolean isStopping?: boolean @@ -242,6 +233,13 @@ export function createStructuredSessionMocks() { supportsStopAll: mocks.supportsBackgroundTaskStopAll }, turnId: mocks.turnId, + commandRefusalCauses: { + working: mocks.turnId !== null || mocks.isWorking, + prompt: mocks.promptItems.length > 0, + background: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks, + sending: false, + retry: false + }, epoch: 'epoch-1', rewind: { surface: undefined }, canStop: mocks.canStop ?? mocks.turnId !== null, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 9da0822c8f92..3149ae3c86a4 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -93,7 +93,9 @@ export function NativeChatStructuredSession( // phases, that empty list must not become the draft's turn baseline. transcriptLoading: controller.status === 'idle' || controller.status === 'loading' }) - const { composerError, reportComposerError } = useNativeChatComposerError() + const { composerError, reportComposerError } = useNativeChatComposerError( + controller.commandRefusalCauses + ) const [optionPickerRequest, setOptionPickerRequest] = useState(null) const rootRef = useRef(null) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index cb73935abfde..a4da39b85fe1 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -37,7 +37,12 @@ const IDS = Object.keys(AGENT_SESSION_FAILURE_COPY).filter( const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [ 'terminalAgentHoldsChat', 'quitTerminalAgent', - 'startNewChat' + 'startNewChat', + 'backgroundTasksRunning', + 'waitForBackgroundTasks', + 'agentStarting', + 'waitForStart', + 'agentStillWorking' ] // Kana, and kanji whose simplified Chinese form differs (続 is 续, 読 is 读, ...). const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 87c5f88accfe..9aa65486b826 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -182,6 +182,34 @@ const PIECES: Record + translate( + 'components.native-chat.writeNotice.backgroundTasksRunning', + COPY.backgroundTasksRunning + ), + waitForBackgroundTasks: () => + translate( + 'components.native-chat.writeNotice.waitForBackgroundTasks', + COPY.waitForBackgroundTasks + ), + agentStarting: () => + translate('components.native-chat.writeNotice.agentStarting', COPY.agentStarting), + waitForStart: () => + translate('components.native-chat.writeNotice.waitForStart', COPY.waitForStart), + agentStillWorking: () => + translate('components.native-chat.writeNotice.agentStillWorking', COPY.agentStillWorking), + runCommandWhenDone: (values) => + translate( + 'components.native-chat.failureWords.runCommandWhenDone', + COPY.runCommandWhenDone, + values + ), + commandAfterAnswer: (values) => + translate( + 'components.native-chat.failureWords.commandAfterAnswer', + COPY.commandAfterAnswer, + values + ), compactionFailed: () => translate('components.native-chat.failureWords.compactionFailed', COPY.compactionFailed), compactionFailedQuoted: (values) => diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 05bd9e47d238..3589b1084a0b 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -110,6 +110,24 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.messagesUnsettled', COPY.messagesUnsettled), settleEarlierMessage: () => translate('components.native-chat.writeNotice.settleEarlierMessage', COPY.settleEarlierMessage), + agentStillWorking: () => + translate('components.native-chat.writeNotice.agentStillWorking', COPY.agentStillWorking), + runClearWhenDone: () => + translate('components.native-chat.writeNotice.runClearWhenDone', COPY.runClearWhenDone), + clearAfterAnswer: () => + translate('components.native-chat.writeNotice.clearAfterAnswer', COPY.clearAfterAnswer), + runCompactWhenDone: () => + translate('components.native-chat.writeNotice.runCompactWhenDone', COPY.runCompactWhenDone), + compactAfterAnswer: () => + translate('components.native-chat.writeNotice.compactAfterAnswer', COPY.compactAfterAnswer), + clearAfterRetry: () => + translate('components.native-chat.writeNotice.clearAfterRetry', COPY.clearAfterRetry), + compactAfterRetry: () => + translate('components.native-chat.writeNotice.compactAfterRetry', COPY.compactAfterRetry), + clearAfterSending: () => + translate('components.native-chat.writeNotice.clearAfterSending', COPY.clearAfterSending), + compactAfterSending: () => + translate('components.native-chat.writeNotice.compactAfterSending', COPY.compactAfterSending), queueTooLarge: () => translate('components.native-chat.writeNotice.queueTooLarge', COPY.queueTooLarge), shrinkQueue: () => translate('components.native-chat.writeNotice.shrinkQueue', COPY.shrinkQueue), 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 1841e6915105..fae1c3cabcea 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 @@ -4,7 +4,10 @@ import type { AgentSessionConversationCommand } from '../../../../shared/agent-s import type { StructuredAgentContextUsage } from '../../../../shared/structured-agent-session-context-usage' import type { AgentSessionSlashCommand } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' -import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer' +import type { + StructuredAgentSessionCommandOutcome, + StructuredAgentSessionCommandRefusalCause +} from '../../../../shared/structured-agent-session-composer' import type { SessionOptionDescriptor, SessionOptionsSurface @@ -15,6 +18,13 @@ import type { NativeChatAfterStopSend } from './native-chat-composer-target' import type { NativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' import type { NativeChatRecallSource } from './native-chat-sent-prompt-history' +export type NativeChatComposerErrorDetail = { + /** Error text Orca did not write, shown apart and copyable. */ + errorText?: string + /** A refused command's cause: its line is said only while that still holds. */ + refusedWhile?: StructuredAgentSessionCommandRefusalCause +} + export type NativeChatOptionPickerRequest = { id: string sequence: number @@ -50,8 +60,7 @@ export type NativeChatStructuredComposerTransport = { worktreeId?: string /** Present only where the host can set this session's goal. */ threadGoal?: { setObjective: (objective: string) => Promise } - /** `errorText` is error text Orca did not write, shown apart and copyable. */ - onError: (message: string | null, errorText?: string) => void + onError: (message: string | null, detail?: NativeChatComposerErrorDetail) => void /** A local send: brings the latest into view at the press, not when the host answers. */ onSubmitted?: () => void runtime: 'local' | 'remote' diff --git a/src/renderer/src/components/native-chat/native-chat-mock-slot-types.test-support.ts b/src/renderer/src/components/native-chat/native-chat-mock-slot-types.test-support.ts new file mode 100644 index 000000000000..4ca845ab45a9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-mock-slot-types.test-support.ts @@ -0,0 +1,13 @@ +// Typed starting values for mutable mock slots, so a test can later assign the wider type. + +export function nullable(): T | null { + return null +} + +export function widened(value: T): T { + return value +} + +export function absent(): T | undefined { + return undefined +} diff --git a/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts index 7a201ec4e10d..5a8793f928f8 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -5,11 +6,11 @@ export async function dispatchNativeChatStructuredComposerText( transport: NativeChatStructuredComposerTransport, text: string, attachments: readonly NativeChatComposerImageAttachment[] = [] -): Promise<{ accepted: boolean; error: string | null; revealsTranscript: boolean }> { - const command = await transport.dispatchCommand(text) +): Promise & { revealsTranscript: boolean }> { + const { handled, ...command } = await transport.dispatchCommand(text) // A command's reveal, if any, came at the press; a message reveals unless it waits as a queued card. - if (command.handled) { - return { accepted: command.accepted, error: command.error, revealsTranscript: false } + if (handled) { + return { ...command, revealsTranscript: false } } const admission = transport.send(text, attachments) return { accepted: admission !== false, error: null, revealsTranscript: admission === true } diff --git a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx index f40d12d87cfd..78d62e8f2e42 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx @@ -240,7 +240,7 @@ describe('structured send racing the next IME composition', () => { changePrompt(input, '/model') await act(async () => pressEnter(input)) - expect(structured.onError).toHaveBeenCalledWith('nope') + expect(structured.onError).toHaveBeenCalledWith('nope', undefined) expect(structured.send).not.toHaveBeenCalled() expect(promptValue(input)).toBe('/model') }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts b/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts new file mode 100644 index 000000000000..1a6d849bf18e --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts @@ -0,0 +1,46 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client' +import { + legacyAgentSessionSelectedOptionId, + type AgentSessionPromptResponse +} from '../../../../shared/agent-session-question-answer' +import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire' +import type { StructuredPromptItem } from './structured-agent-session-message-projection' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' + +export async function respondToStructuredAgentSessionPrompt({ + item, + response, + target, + mutate +}: { + item: StructuredPromptItem + response: AgentSessionPromptResponse + target: RuntimeClientTarget + mutate: StructuredAgentSessionMutate +}): Promise { + const promptTarget = { itemId: item.itemId, expectedRevision: item.revision } + let fields: Record + if (response.kind === 'option') { + fields = { ...promptTarget, optionId: response.optionId } + } else if (await supportsStructuredAgentSessionQuestionAnswers(target)) { + // Negotiated before mutate fingerprints the call: older hosts reject the strict field. + fields = { ...promptTarget, answers: response.answers } + } else { + const optionId = + item.body.kind === 'question' + ? legacyAgentSessionSelectedOptionId(item.body, response.answers) + : null + if (optionId === null) { + return null + } + fields = { ...promptTarget, optionId } + } + return mutate( + item.body.kind === 'approval' + ? 'agentSession.respondToApproval' + : 'agentSession.respondToQuestion', + `agentSession.respondTo:${item.body.kind}`, + fields + ) +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index c7efd15b5087..73a3c3ab1bed 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionPendingSend } from './structured-agent-sessi import { newestSteerableQueuedMessageCard, pendingSendsOutsideQueuedCards, + pendingQueueSendsOnTheirWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause @@ -208,6 +209,31 @@ describe('queued message cards', () => { expect(newestSteerableQueuedMessageCard([])).toBeNull() }) + it('marks a command card, which the chord never steers', () => { + const compact: AgentSessionQueuedMessage = { + ...draft('c', 2), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + } + const cards = projectQueuedMessageCards([draft('a', 1), compact], [], IDLE) + expect(cards.map((card) => [card.text, card.command ?? false])).toEqual([ + ['text of a', false], + ['/compact', true] + ]) + expect(newestSteerableQueuedMessageCard(cards)).toBeNull() + // While the agent works it offers no send; a message card is unaffected. + const working = projectQueuedMessageCards([draft('a', 1), compact], [], { + ...IDLE, + agentWorking: true + }) + expect(working.map((card) => card.waitsForAgent ?? false)).toEqual([false, true]) + expect(cards[1]).not.toHaveProperty('waitsForAgent') + }) + it('a mid-turn queue send on its way is no bubble; a plain or recorded one is, until its row', () => { const entry = ( clientMessageId: string, @@ -243,6 +269,28 @@ describe('queued message cards', () => { 'recorded' ]) }) + it('a sending card ends once the host records the send or hands its card off', () => { + const entry: StructuredAgentSessionPendingSend = { + clientMessageId: 'a', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'a' }] }, + previewUris: [], + queuedAt: 1, + phase: 'sending', + issued: true, + delivery: 'queue-if-active' + } + const onItsWay = (submissions: AgentJournalSubmission[]) => + pendingQueueSendsOnTheirWay([entry], [], true, submissions).map( + (candidate) => candidate.clientMessageId + ) + expect(onItsWay([])).toEqual(['a']) + expect(onItsWay([submission('a')])).toEqual([]) + expect(onItsWay([handOff('a')])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([entry], ['a'], true, [])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([entry], [], false, [])).toEqual([]) + expect(pendingQueueSendsOnTheirWay([{ ...entry, phase: 'recorded' }], [], true, [])).toEqual([]) + }) }) describe("another agent's card", () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e26197360702..4192b552d791 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -11,6 +11,7 @@ import { readAgentMessageSource, type AgentMessageSource } from '../../../../shared/agent-session-message-source' +import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' /** Why a card is not on its way right now; decides the caption under the text. */ @@ -23,6 +24,9 @@ export type QueuedMessageCardHold = | 'paused' | 'behind-returned' | 'returned' + /** On its way to the host, which holds no card for it yet: it reads as sending, and nothing + * can act on it until the host's card replaces it under the same id. */ + | 'sending' export type QueuedMessageCard = { messageId: string @@ -31,6 +35,10 @@ export type QueuedMessageCard = { text: string state: 'waiting' | 'returned' hold: QueuedMessageCardHold + /** A conversation command such as /compact: it never steers into a running turn. */ + command?: true + /** A command card while the agent works: it offers no send until the agent is idle. */ + waitsForAgent?: true pausedReason?: string returnedReason?: string | null /** The typed fact the returned card's submission settled with; read like its `rejection`. */ @@ -53,7 +61,7 @@ function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string export function projectQueuedMessageCards( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, submissions: readonly AgentJournalSubmission[], - session: { hasPendingPrompt: boolean; queuePaused?: boolean } + session: { hasPendingPrompt: boolean; queuePaused?: boolean; agentWorking?: boolean } ): QueuedMessageCard[] { const handedOff = handedOffQueuedMessageIds( submissions.filter((submission) => submission.dispatchState !== 'rejected') @@ -83,6 +91,12 @@ export function projectQueuedMessageCards( text: queuedMessageCardText(message.body), state: message.state, hold, + ...(message.body.command !== undefined + ? { + command: true as const, + ...(session.agentWorking ? { waitsForAgent: true as const } : {}) + } + : {}), ...(message.pausedReason !== undefined ? { pausedReason: message.pausedReason } : {}), ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), ...(message.returnedRejection !== undefined @@ -93,6 +107,17 @@ export function projectQueuedMessageCards( }) } +/** A command card waits in line: a later send goes behind it, even with follow-ups off. A card + * held on its own (kept, couldn't send) is skipped by the queue, so nothing is behind it. */ +export function commandCardWaiting( + queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined +): boolean { + return (queuedMessages ?? []).some( + (message) => + message.state === 'waiting' && !message.paused && message.body.command !== undefined + ) +} + /** The pause the header row names, while it holds a card. A pause over cards Resume would not * send (returned, held on their own, or behind a returned one) offers nothing to press. */ export function queuedMessagesQueuePause( @@ -103,16 +128,31 @@ export function queuedMessagesQueuePause( } /** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own or - * returned is not waiting on the turn, so its action is plainly Send. */ + * returned is not waiting on the turn, so its action is plainly Send. A command never steers. */ export function queuedMessageCardSteers(card: QueuedMessageCard): boolean { - return card.hold !== 'paused' && card.hold !== 'returned' + return card.hold !== 'paused' && card.hold !== 'returned' && !card.command } -/** The card Cmd/Ctrl+Enter steers: the newest one; every shown card takes Send-now. */ +/** The card Cmd/Ctrl+Enter steers: the newest one, unless it is a command, which never steers. */ export function newestSteerableQueuedMessageCard( cards: readonly QueuedMessageCard[] ): QueuedMessageCard | null { - return cards.at(-1) ?? null + const newest = cards.at(-1) + return newest && !newest.command && newest.hold !== 'sending' ? newest : null +} + +/** A queue send still on its way, as the card it is about to become. */ +export function sendingQueuedMessageCards( + entries: readonly StructuredAgentSessionPendingSend[] +): QueuedMessageCard[] { + return entries.map((entry, index) => ({ + messageId: entry.clientMessageId, + // After every card the host holds, in send order. + position: Number.MAX_SAFE_INTEGER - entries.length + index, + text: queuedMessageCardText(entry.body), + state: 'waiting', + hold: 'sending' + })) } /** @@ -130,3 +170,26 @@ export function pendingSendsOutsideQueuedCards< ) return next.length === pending.length ? pending : next } + +/** Queue sends without a host card or submission yet, drawn as sending cards. */ +export function pendingQueueSendsOnTheirWay( + pending: readonly StructuredAgentSessionPendingSend[], + heldIds: readonly string[], + isWorking: boolean, + submissions: readonly AgentJournalSubmission[] +): StructuredAgentSessionPendingSend[] { + if (!isWorking) { + return [] + } + const recorded = new Set([ + ...heldIds, + ...handedOffQueuedMessageIds(submissions), + ...submissions.map((submission) => submission.clientMessageId) + ]) + return pending.filter( + (entry) => + entry.phase === 'sending' && + entry.delivery === 'queue-if-active' && + !recorded.has(entry.clientMessageId) + ) +} diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index a972b660063b..c765001325a4 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts @@ -1,11 +1,23 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { i18n } from '@/i18n/i18n' import type { AgentSessionConversationCommandResult } from '../../../../shared/agent-session-conversation-command' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' -import { sendStructuredConversationCommand } from './structured-conversation-command-send' +import { + sendStructuredConversationCommand, + structuredConversationCommandHold, + type StructuredConversationCommandCauses +} from './structured-conversation-command-send' + +const NO_CAUSES: StructuredConversationCommandCauses = { + working: false, + prompt: false, + background: false, + sending: false, + retry: false +} const START_FAILED: AgentSessionFailureFact = { kind: 'startFailed' } const COMPACTION_FAILED: AgentSessionFailureFact = { @@ -59,7 +71,8 @@ async function sent( command: result.command, agentName: structuredAgentLabel(provider), pending: { current: false }, - blocked: false, + hold: null, + causes: NO_CAUSES, startFailures: () => [], send: async () => ({ kind: 'done', value: result }) }) @@ -154,7 +167,8 @@ describe('the line under the composer after a conversation command failed', () = command: 'compact', agentName: 'Claude', pending: { current: false }, - blocked: false, + hold: null, + causes: NO_CAUSES, startFailures: () => [START_FAILED], send: async () => ({ kind: 'done', value: result }) }) @@ -176,3 +190,193 @@ describe('the line under the composer after a conversation command failed', () = }) }) }) + +describe('a /compact the host holds in line', () => { + it('reads its queued answer as sent, with no line under the composer', async () => { + expect( + await sent({ + command: 'compact', + state: 'completed', + queued: { messageId: 'op-1', position: 1, state: 'waiting' } + }) + ).toEqual({ accepted: true, error: null }) + }) + + const idle = { + waitsInLine: false, + agentWorking: false, + promptPending: false, + backgroundTasksRunning: false, + sendPending: false + } + + it('is held here only by a message the host does not have yet, or background work', () => { + const inLine = { ...idle, waitsInLine: true } + expect( + structuredConversationCommandHold({ + ...inLine, + agentWorking: true, + promptPending: true + }) + ).toBeNull() + expect(structuredConversationCommandHold({ ...inLine, sendPending: true })).toBe('ahead') + expect(structuredConversationCommandHold({ ...inLine, backgroundTasksRunning: true })).toBe( + 'background' + ) + }) + + it('against a host that cannot hold it, and for /clear, keeps every check in true words', () => { + expect(structuredConversationCommandHold(idle)).toBeNull() + expect(structuredConversationCommandHold({ ...idle, agentWorking: true })).toBe('working') + // The agent idle, only this window's own message still on its way: it is still being sent. + expect(structuredConversationCommandHold({ ...idle, sendPending: true })).toBe('sending') + expect( + structuredConversationCommandHold({ ...idle, agentWorking: true, sendPending: true }) + ).toBe('working') + expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt') + }) +}) + +function held( + command: 'clear' | 'compact', + hold: Parameters[0]['hold'], + pending = { current: false }, + causes = NO_CAUSES +) { + const send = vi.fn(async () => ({ + kind: 'done' as const, + value: { command, state: 'completed' as const } + })) + return { + send, + result: sendStructuredConversationCommand({ + command, + agentName: structuredAgentLabel('claude'), + pending, + hold, + causes, + startFailures: () => [], + send + }) + } +} + +describe('a command held here', () => { + it('behind a message still on its way: not taken, no line, nothing left to go out later', async () => { + const { send, result } = held('compact', 'ahead') + expect(await result).toEqual({ accepted: false, error: null }) + expect(send).not.toHaveBeenCalled() + }) + + it('a second command while one is on its way says the agent is working', async () => { + const { send, result } = held('clear', null, { current: true }) + expect(await result).toEqual({ + accepted: false, + error: "The agent is still working. Run /clear when it's done." + }) + expect(send).not.toHaveBeenCalled() + }) + + it('behind a message still being sent after a Stop, says so, naming no Retry', async () => { + expect(await held('clear', 'sending').result).toEqual({ + accepted: false, + error: 'Your earlier message is still being sent. Run /clear once it has gone.' + }) + }) + + it('a /clear while the agent works says so in plain words, once', async () => { + expect(await held('clear', 'working').result).toEqual({ + accepted: false, + error: "The agent is still working. Run /clear when it's done." + }) + expect(await held('clear', 'prompt').result).toEqual({ + accepted: false, + error: "Answer the agent's question or approval, then run /clear." + }) + }) + + it('a /compact a host without the queue refuses reads the same way', async () => { + expect(await held('compact', 'working').result).toEqual({ + accepted: false, + error: "The agent is still working. Run /compact when it's done." + }) + expect(await held('compact', 'prompt').result).toEqual({ + accepted: false, + error: "Answer the agent's question or approval, then run /compact." + }) + }) +}) + +describe('a refusal names what it waits on only while the chat shows it', () => { + const shown = (cause: keyof StructuredConversationCommandCauses) => ({ + ...NO_CAUSES, + [cause]: true + }) + const refusedBy = ( + reason: 'turnActive' | 'messagesUnsettled' | 'promptPending' | 'backgroundTasksRunning' + ): AgentSessionConversationCommandResult => + hostResult('clear', { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason } } + }) + const hostAnswers = ( + result: AgentSessionConversationCommandResult, + causes: StructuredConversationCommandCauses + ) => + sendStructuredConversationCommand({ + command: result.command, + agentName: structuredAgentLabel('claude'), + pending: { current: false }, + hold: null, + causes, + startFailures: () => [], + send: async () => ({ kind: 'done', value: result }) + }) + + it('held here: the agent working, a prompt, background tasks, or a message not yet gone', async () => { + const pending = { current: false } + expect((await held('clear', 'working', pending, shown('working')).result).refusedWhile).toBe( + 'working' + ) + expect((await held('clear', 'prompt', pending, shown('prompt')).result).refusedWhile).toBe( + 'prompt' + ) + expect( + (await held('compact', 'background', pending, shown('background')).result).refusedWhile + ).toBe('background') + expect((await held('clear', 'sending', pending, shown('sending')).result).refusedWhile).toBe( + 'sending' + ) + }) + + it('names none the chat does not show, so the line cannot go before it is read', async () => { + // A rewind on its way holds the command in background words, with no strip to watch. + const rewinding = await held('compact', 'background').result + expect(rewinding.error).toBeTruthy() + expect(rewinding).not.toHaveProperty('refusedWhile') + // A command on its way is not something the chat shows ending. + const second = await held('clear', null, { current: true }, shown('working')).result + expect(second).not.toHaveProperty('refusedWhile') + }) + + it('from the host: the reason its words name, when the chat shows it', async () => { + for (const reason of ['turnActive', 'messagesUnsettled'] as const) { + expect((await hostAnswers(refusedBy(reason), shown('working'))).refusedWhile).toBe('working') + } + expect((await hostAnswers(refusedBy('promptPending'), shown('prompt'))).refusedWhile).toBe( + 'prompt' + ) + const background = await hostAnswers(refusedBy('backgroundTasksRunning'), shown('background')) + expect(background).toEqual({ + accepted: false, + error: + 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.', + refusedWhile: 'background' + }) + // Ahead of the chat, or a reason that names nothing it shows: said as any failure. + expect(await hostAnswers(refusedBy('turnActive'), NO_CAUSES)).not.toHaveProperty('refusedWhile') + expect( + await hostAnswers(hostResult('compact', COMPACTION_FAILED), shown('working')) + ).not.toHaveProperty('refusedWhile') + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts index 0a351db917f1..47ae05dcbc6d 100644 --- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts +++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts @@ -10,29 +10,133 @@ import { import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { translate } from '@/i18n/i18n' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' -import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' +import { + agentSessionFailureStatedByStartRow, + structuredAgentSessionStartFailureFacts +} from './structured-agent-session-delivery-notices' +import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' +import type { StructuredPromptItem } from './structured-agent-session-message-projection' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' +import { structuredAgentSessionCommandHostRefusalCause } from '../../../../shared/structured-agent-session-command-refusal-cause' +import type { + StructuredAgentSessionCommandOutcome, + StructuredAgentSessionCommandRefusalCause +} from '../../../../shared/structured-agent-session-composer' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' +/** Why this client does not send the command yet. `ahead`: a message this window sent is not + * the host's yet; the command is not taken, and its text stays in the composer while Send is + * busy, as for any send on its way. The rest are refused here, in words. */ +export type StructuredConversationCommandHold = + | 'ahead' + | 'working' + | 'prompt' + | 'background' + | 'sending' + +/** A command that waits in line is held only by a message the host doesn't have yet, which must + * stay ahead of it; any other command, by anything the agent still has in flight. */ +export function structuredConversationCommandHold(input: { + /** A /compact the host can hold as a card behind the turn or prompt. */ + waitsInLine: boolean + /** A turn runs, or the chat shows the agent working on a message it has not answered. */ + agentWorking: boolean + promptPending: boolean + backgroundTasksRunning: boolean + /** This chat's one message request has not settled yet. */ + sendPending: boolean +}): StructuredConversationCommandHold | null { + if (input.backgroundTasksRunning) { + return 'background' + } + if (input.waitsInLine) { + return input.sendPending ? 'ahead' : null + } + if (input.promptPending) { + return 'prompt' + } + if (input.agentWorking) { + return 'working' + } + // The agent is idle and this window's own message hasn't reached the host: that is the wait. + if (input.sendPending) { + return 'sending' + } + return null +} + +type CommandOutcome = Omit + +/** Which of what a refused command waits on the chat shows right now. */ +export type StructuredConversationCommandCauses = Readonly< + Record +> + +/** The line a command refused here gets: what the person sees and can do, as the host says it. */ +function heldCommandText( + command: AgentSessionConversationCommand, + hold: Exclude +): string { + const clear = command === 'clear' + switch (hold) { + case 'prompt': + return agentSessionWriteNoticeText([clear ? 'clearAfterAnswer' : 'compactAfterAnswer']) + case 'working': + return agentSessionWriteNoticeText([ + 'agentStillWorking', + clear ? 'runClearWhenDone' : 'runCompactWhenDone' + ]) + case 'sending': + return agentSessionWriteNoticeText([clear ? 'clearAfterSending' : 'compactAfterSending']) + case 'background': + break + } + return translate( + 'components.native-chat.conversationCommand.pendingWork', + 'Wait for pending work and messages to finish before using this command.' + ) +} + export async function sendStructuredConversationCommand(input: { command: AgentSessionConversationCommand /** The chat's agent, as a failed command names it. */ agentName: string pending: { current: boolean } - blocked: boolean + hold: StructuredConversationCommandHold | null + /** What the chat showed at the press. A refusal names its cause only when the chat showed it, + * so a host ahead of the chat can't make the line go the moment it lands; else it is said as + * any other failure. */ + causes: StructuredConversationCommandCauses /** What the chat's loaded start-failure rows state, read when the reply lands. */ startFailures: () => readonly AgentSessionFailureFact[] send: ( command: AgentSessionConversationCommand ) => Promise> -}): Promise<{ accepted: boolean; error: string | null }> { - if (input.pending.current || input.blocked) { - return { - accepted: false, - error: translate( - 'components.native-chat.conversationCommand.pendingWork', - 'Wait for pending work and messages to finish before using this command.' - ) - } +}): Promise { + const refused = ( + error: string | null, + cause: StructuredAgentSessionCommandRefusalCause | undefined + ): CommandOutcome => ({ + accepted: false, + error, + ...(error && cause && input.causes[cause] ? { refusedWhile: cause } : {}) + }) + // A command on its way is the agent's work in flight. + if (input.pending.current) { + return { accepted: false, error: heldCommandText(input.command, 'working') } + } + // The message ahead reads as sending, and Send is busy until the host has it; nothing is armed. + if (input.hold === 'ahead') { + return { accepted: false, error: null } + } + if (input.hold !== null) { + // Each hold here is named for the cause it waits on. + return refused(heldCommandText(input.command, input.hold), input.hold) } input.pending.current = true try { @@ -56,12 +160,88 @@ export async function sendStructuredConversationCommand(input: { return { accepted: false, error: null } } const error = conversationCommandFailureText(value, input.agentName) - return { accepted: value.state === 'completed' && !error, error } + if (value.state === 'completed' && !error) { + return { accepted: true, error: null } + } + return refused(error, structuredAgentSessionCommandHostRefusalCause(value)) } finally { input.pending.current = false } } +/** The composer's /clear or /compact, with what this pane knows about work in flight; and which + * of what a refusal waits on the chat shows, which its line stands on. */ +export function structuredConversationCommandRunner(args: { + agentName: string + pending: { current: boolean } + /** The host holds a /compact as a card, and this client renders the queue. */ + commandsWait: boolean + /** What the chat shows: a turn, the Working rule, its background work, its sends. */ + chat: { + turnId: string | null + isWorking: boolean + /** The queue's send is the host's next update: the pane reads working. */ + queueSendsNext: boolean + backgroundTasks: { isMonitoring: boolean; show: boolean } + submissions: readonly AgentJournalSubmission[] + } + /** The chat's pending prompts: a card would wait forever on ones this build cannot answer. */ + prompts: readonly StructuredPromptItem[] + /** A rewind this pane started is on its way; read at the press. */ + rewindInFlight: { readonly current: boolean } + sends: readonly StructuredAgentSessionPendingSend[] + /** The loaded rows, read when the reply lands, for the start failures they state. */ + items: () => readonly AgentJournalRenderItem[] + send: ( + command: AgentSessionConversationCommand, + delivery?: 'queue-if-active' + ) => Promise> +}): { + runConversationCommand: (command: AgentSessionConversationCommand) => Promise + /** What a refusal's line stands on, as the chat shows it now. */ + commandRefusalCauses: StructuredConversationCommandCauses +} { + const promptPending = args.prompts.length > 0 + const agentWorking = args.chat.turnId !== null || args.chat.isWorking || args.chat.queueSendsNext + const sendPending = args.sends.some((entry) => entry.phase === 'sending') + const causes: StructuredConversationCommandCauses = { + working: agentWorking, + prompt: promptPending, + // The strip, mid-turn included: what a host refusing on background tasks points at. + background: args.chat.backgroundTasks.show || args.chat.backgroundTasks.isMonitoring, + sending: sendPending, + retry: false + } + const run = (command: AgentSessionConversationCommand) => { + const waitsInLine = + command === 'compact' && + args.commandsWait && + !(promptPending && pendingPromptsAllUnanswerableHere(args.prompts)) + return sendStructuredConversationCommand({ + command, + agentName: args.agentName, + pending: args.pending, + hold: structuredConversationCommandHold({ + waitsInLine, + agentWorking, + promptPending, + // A rewind on its way holds a command as background work does, in the same words. + backgroundTasksRunning: + args.chat.backgroundTasks.isMonitoring || args.rewindInFlight.current, + sendPending + }), + causes, + startFailures: () => structuredAgentSessionStartFailureFacts(args.items()), + send: (command) => + args.send( + command, + command === 'compact' && args.commandsWait ? 'queue-if-active' : undefined + ) + }) + } + return { runConversationCommand: run, commandRefusalCauses: causes } +} + /** The host's sentence in the reader's language, from the fact beside it; with no fact (an older * host), one this build can't read whole, or a command it doesn't know, the sentence as written. */ function conversationCommandFailureText( diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts index 68935493bdc0..5c488ab8cbcf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts @@ -3,6 +3,14 @@ import type { NativeChatComposerNotice, NativeChatComposerNoticeContent } from './native-chat-composer-notice' +import type { + NativeChatComposerErrorDetail, + NativeChatStructuredComposerTransport +} from './native-chat-composer-types' +import type { StructuredConversationCommandCauses } from './structured-conversation-command-send' + +type ComposerErrorLine = NativeChatComposerNoticeContent & + Pick /** The composer's own paste and attachment notice, after the chat's notices. */ export function useNativeChatComposerNotice(chatNotices?: readonly NativeChatComposerNotice[]): { @@ -35,20 +43,40 @@ export function useNativeChatComposerNotice(chatNotices?: readonly NativeChatCom } /** A chat's last send or command error: shown in the card until dismissed or the next send clears - * it. */ -export function useNativeChatComposerError(): { + * it. A command's refusal names what the chat shows it waiting on, so it goes once that is gone. */ +export function useNativeChatComposerError(causes: StructuredConversationCommandCauses): { composerError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null - reportComposerError: (text: string | null, errorText?: string) => void + reportComposerError: NativeChatStructuredComposerTransport['onError'] } { - const [content, setContent] = useState(null) - const reportComposerError = useCallback( - (text: string | null, errorText?: string) => - setContent(text === null ? null : { text, ...(errorText ? { errorText } : {}) }), + const [line, setLine] = useState(null) + const reportComposerError = useCallback( + (text, detail) => + setLine( + text === null + ? null + : { + text, + ...(detail?.errorText ? { errorText: detail.errorText } : {}), + ...(detail?.refusedWhile ? { refusedWhile: detail.refusedWhile } : {}) + } + ), [] ) + const shown = line !== null && (!line.refusedWhile || causes[line.refusedWhile]) ? line : null + // Dropped, not hidden: the cause coming back later is not what this refusal was about. + if (line !== null && shown === null) { + setLine(null) + } const composerError = useMemo( - () => (content ? { ...content, onDismiss: () => setContent(null) } : null), - [content] + () => + shown + ? { + text: shown.text, + ...(shown.errorText ? { errorText: shown.errorText } : {}), + onDismiss: () => setLine(null) + } + : null, + [shown] ) return { composerError, reportComposerError } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index 18fc826a30a0..f1615691bdbf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -137,7 +137,8 @@ describe('reports the sends that bring the latest into view', () => { send('/permissions', []) await vi.waitFor(() => expect(structuredTransport.onError).toHaveBeenCalledWith( - expect.stringContaining('not available in chat sessions') + expect.stringContaining('not available in chat sessions'), + undefined ) ) expect(onSubmitted).not.toHaveBeenCalled() @@ -154,10 +155,9 @@ describe("says a failed send in Orca's words, with the error it hit apart", () = }) send('hello', []) await vi.waitFor(() => - expect(structuredTransport.onError).toHaveBeenCalledWith( - 'Your message was not sent.', - 'connect ECONNREFUSED /tmp/a.sock' - ) + expect(structuredTransport.onError).toHaveBeenCalledWith('Your message was not sent.', { + errorText: 'connect ECONNREFUSED /tmp/a.sock' + }) ) }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index b52e0483d46b..1f0ce4e27b93 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -92,8 +92,8 @@ export function useNativeChatStructuredComposerSend({ } const submitted = sentFrom ?? readNativeChatComposerDraft(draftScopeKey) await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) - .then(({ accepted, error, revealsTranscript }) => { - structuredTransport.onError(error) + .then(({ accepted, error, refusedWhile, revealsTranscript }) => { + structuredTransport.onError(error, refusedWhile ? { refusedWhile } : undefined) if (!accepted) { return } @@ -129,7 +129,7 @@ export function useNativeChatStructuredComposerSend({ error, agentSessionWriteNoticeText([hostCommand ? 'notDoneCommand' : 'notDoneSend']) ) - structuredTransport.onError(notice.text, notice.errorText) + structuredTransport.onError(notice.text, { errorText: notice.errorText }) }) }, [ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx index 9e504d5ae5b7..1326b1f16a06 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx @@ -1,13 +1,14 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' -import { beforeEach, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn(), outboxSend: vi.fn() })) let items: AgentJournalRenderItem[] = [] +let backgroundTasks: AgentSessionBackgroundTaskState | undefined vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call @@ -22,7 +23,8 @@ vi.mock('./use-structured-agent-session-read', () => ({ status: 'ready', error: null, hasOlder: false, - handoff: null + handoff: null, + ...(backgroundTasks ? { backgroundTasks } : {}) }, loadingOlder: false, loadOlder: vi.fn() @@ -42,6 +44,7 @@ vi.mock('./use-structured-agent-session-sends', () => ({ })) import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-background-task-wire' import { useStructuredAgentSession } from './use-structured-agent-session' function answer(text: string, scoped: boolean): AgentJournalRenderItem { @@ -58,6 +61,7 @@ function answer(text: string, scoped: boolean): AgentJournalRenderItem { beforeEach(() => { vi.clearAllMocks() mocks.outboxSend.mockReturnValue(true) + backgroundTasks = undefined }) /** Starts `/compact` and leaves its reply outstanding, then sends a message. */ @@ -92,3 +96,59 @@ it('queues a message typed during a command on a host that runs it as a turn', ( expect(sendDuringCommand()).toBe(true) expect(mocks.outboxSend).toHaveBeenCalledOnce() }) + +// What a refused /clear names goes from the chat with its cause, so its line can go too. +describe('a /clear refused for something the chat shows', () => { + const renderSession = () => + renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + async function clear(result: { current: ReturnType }) { + let outcome: unknown + await act(async () => { + outcome = await result.current.runConversationCommand('clear') + }) + return outcome + } + + it('while the agent works: named working, which ends when the agent stops', async () => { + items = [ + { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } + } + ] + const { result, rerender } = renderSession() + expect(await clear(result)).toEqual({ + accepted: false, + error: "The agent is still working. Run /clear when it's done.", + refusedWhile: 'working' + }) + expect(result.current.commandRefusalCauses.working).toBe(true) + items = [] + rerender() + expect(result.current.commandRefusalCauses.working).toBe(false) + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.conversationCommand') + ).toHaveLength(0) + }) + + it('while background tasks run: named background, which ends with the last task', async () => { + items = [] + backgroundTasks = { state: 'monitoring', tasks: [{ id: 'task-1', kind: 'command' }] } + const { result, rerender } = renderSession() + expect(await clear(result)).toMatchObject({ accepted: false, refusedWhile: 'background' }) + expect(result.current.commandRefusalCauses.background).toBe(true) + backgroundTasks = undefined + rerender() + expect(result.current.commandRefusalCauses.background).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts index 36d798414a89..54b0df881b3f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts @@ -20,7 +20,8 @@ export function useStructuredAgentSessionCommandWrite( sessionId: string, write: StructuredAgentSessionWrite ): ( - command: AgentSessionConversationCommand + command: AgentSessionConversationCommand, + delivery?: 'queue-if-active' ) => Promise> { // Null once the view left or unmounted, so a reply that lands later releases its hold. const shown = useRef(sessionId) @@ -34,12 +35,12 @@ export function useStructuredAgentSessionCommandWrite( } }, [sessionId]) return useCallback( - async (command) => { + async (command, delivery) => { const send = () => write( 'agentSession.conversationCommand', 'agentSession.conversationCommand', - { command } + { command, ...(delivery ? { delivery } : {}) } ) if (command !== 'clear') { return send() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index dfb5a211c5c8..b56d5b0eb916 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -17,11 +17,14 @@ import type { AgentSessionSendResult } from '../../../../shared/agent-session-wire' import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + pendingQueueSendsOnTheirWay, queuedMessagesQueuePause, + sendingQueuedMessageCards, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -61,6 +64,8 @@ export type StructuredAgentSessionQueueResume = { resuming: boolean } +const NO_SENDS: readonly StructuredAgentSessionPendingSend[] = [] + function alreadySentNotice(): void { toast.error( translate('components.native-chat.queuedMessages.alreadySent', 'This message was already sent.') @@ -76,19 +81,33 @@ export function useStructuredAgentSessionQueuedMessages(args: { hasPendingPrompt: boolean /** A turn is running, whoever started it, or the queue is about to send its next card. */ isWorking: boolean + /** This pane's sends without a host record yet show as sending cards. */ + sending?: readonly StructuredAgentSessionPendingSend[] composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - const { queuePause } = args + const { isWorking, queuePause } = args + const sending = args.sending ?? NO_SENDS const cards = useMemo( - () => - projectQueuedMessageCards(queuedMessages, submissions, { + () => [ + ...projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, + // A command card offers no send while the agent works. + agentWorking: isWorking, queuePaused: queuePause !== null }), - [hasPendingPrompt, queuePause, queuedMessages, submissions] + ...sendingQueuedMessageCards( + pendingQueueSendsOnTheirWay( + sending, + (queuedMessages ?? []).map((message) => message.messageId), + isWorking, + submissions + ) + ) + ], + [hasPendingPrompt, isWorking, sending, queuePause, queuedMessages, submissions] ) const cardsRef = useRef(cards) useEffect(() => { @@ -157,7 +176,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { // 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) { + if (!card || card.command || !composerScopeKey) { return } appendNativeChatDraftCache(composerScopeKey, card.text) @@ -219,7 +238,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { // Resume and the "Send message?" choice only where the queue could send now: no turn runs (the // queue's coming send counts, as the host names it) and no prompt waits, which holds the queue // too; the composer shows beside a prompt only when this build cannot answer it. - const held = enabled && pause !== null && !args.isWorking && !hasPendingPrompt + const held = enabled && pause !== null && !isWorking && !hasPendingPrompt const queueResume = useMemo( () => (held ? { resume, resuming } : undefined), [held, resume, resuming] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index b6ef99e9e4f1..96a7b3c8d2a4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -256,8 +256,7 @@ describe('a chat write the host refused', () => { await expect(result.current.cancel('turn-1')).resolves.toBeNull() await expect(result.current.runConversationCommand('compact')).resolves.toEqual({ accepted: false, - error: - "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." + error: "The agent is still working. Run /compact when it's done." }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index dae635600e69..52736ea98470 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -9,19 +9,27 @@ 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 { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import type * as RewindModule from './use-native-chat-rewind' const mocks = vi.hoisted(() => ({ call: vi.fn(), - outboxArgs: Array.of<{ queue?: { capability: string; enabled: boolean } }>(), - operations: 0 + sendArgs: Array.of<{ queue?: { capability: string; enabled: boolean } }>(), + operations: 0, + rewindPending: false })) let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined +let submissions: AgentJournalSubmission[] = [] let nextQueuedMessageId: string | null = null -let outboxEntries: StructuredAgentSessionPendingSend[] = [] +let pendingSends: StructuredAgentSessionPendingSend[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call, @@ -33,7 +41,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions: [], + submissions, status: 'ready', error: null, hasOlder: false, @@ -44,11 +52,25 @@ vi.mock('./use-structured-agent-session-read', () => ({ }) })) +// The real rewind hook, with only its in-flight latch forced when a test says so. +vi.mock('./use-native-chat-rewind', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useStructuredAgentSessionRewind: ( + ...args: Parameters + ) => { + const rewind = actual.useStructuredAgentSessionRewind(...args) + return mocks.rewindPending ? { ...rewind, blockedRef: { current: true } } : rewind + } + } +}) + vi.mock('./use-structured-agent-session-sends', () => ({ useStructuredAgentSessionSends: (args: { queue?: { capability: string; enabled: boolean } }) => { - mocks.outboxArgs.push(args) + mocks.sendArgs.push(args) return { - pending: outboxEntries, + pending: pendingSends, error: null, send: vi.fn(), stopSends: vi.fn() @@ -58,6 +80,7 @@ vi.mock('./use-structured-agent-session-sends', () => ({ import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' @@ -130,7 +153,8 @@ function cancels(): unknown[] { beforeEach(() => { vi.clearAllMocks() - mocks.outboxArgs.length = 0 + mocks.sendArgs.length = 0 + mocks.rewindPending = false mocks.call.mockImplementation(async (_target, method) => method === 'agentSession.cancel' ? { @@ -144,8 +168,9 @@ beforeEach(() => { ) items = [RUNNING_TURN] queuedMessages = undefined + submissions = [] nextQueuedMessageId = null - outboxEntries = [] + pendingSends = [] localStorage.clear() clearNativeChatDraftCacheForTests() }) @@ -164,13 +189,13 @@ describe('against a capable host', () => { it('queues sends while the setting is on, immediately when it is off', () => { render() - expect(mocks.outboxArgs.at(-1)?.queue).toEqual({ + expect(mocks.sendArgs.at(-1)?.queue).toEqual({ capability: 'supported', enabled: true }) - mocks.outboxArgs.length = 0 + mocks.sendArgs.length = 0 render(false) - expect(mocks.outboxArgs.at(-1)?.queue).toEqual({ + expect(mocks.sendArgs.at(-1)?.queue).toEqual({ capability: 'supported', enabled: false }) @@ -202,14 +227,14 @@ describe('against a capable host', () => { const newer = newerApproval() items = [newer] render() - expect(mocks.outboxArgs.at(-1)?.queue).toEqual({ + expect(mocks.sendArgs.at(-1)?.queue).toEqual({ capability: 'supported', enabled: false }) - mocks.outboxArgs.length = 0 + mocks.sendArgs.length = 0 items = [newer, approval({ kind: 'plan', text: 'do it' })] render() - expect(mocks.outboxArgs.at(-1)?.queue).toEqual({ + expect(mocks.sendArgs.at(-1)?.queue).toEqual({ capability: 'supported', enabled: true }) @@ -310,7 +335,7 @@ describe('against a capable host', () => { issued: true, ...(delivery ? { delivery } : {}) }) - outboxEntries = [ + pendingSends = [ // On its way asking to be queued: its card, not a bubble, shows it. entry('pending-queue', 'awaiting the answer', 'queue-if-active'), // Sent plain: a bubble. @@ -338,10 +363,13 @@ describe('against a capable host', () => { const { result } = render() expect(result.current).toMatchObject({ isWorking: true, queueSendsNext: true, canStop: false }) expect(result.current.queuedMessages.cards.map((card) => card.hold)).toEqual(['turn']) + // A command refused now is said while the pane reads working, and no longer. + expect(result.current.commandRefusalCauses.working).toBe(true) // Where the host would refuse that send, it names none: the chat reads idle. nextQueuedMessageId = null const refused = render() expect(refused.result.current).toMatchObject({ isWorking: false, queueSendsNext: false }) + expect(refused.result.current.commandRefusalCauses.working).toBe(false) }) it('shows host-held drafts as cards, never as transcript bubbles', () => { @@ -353,6 +381,358 @@ describe('against a capable host', () => { }) }) +function commandCalls(): unknown[] { + return mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.conversationCommand') + .map(([, , params]) => params) +} + +function answerCommands(value: Record): void { + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.conversationCommand' + ? { ok: true, replayed: false, fence: 3, cursor: { epoch: 'e', sequence: 1 }, value } + : method === 'agentSession.cancel' + ? { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'e', sequence: 1 }, + value: { cancelled: true } + } + : null + ) +} + +describe('a /compact against a host that holds commands in line', () => { + beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY + ]) + }) + + it('mid-turn, goes to the host asking to wait, and its queued answer shows no notice', async () => { + answerCommands({ + command: 'compact', + state: 'completed', + queued: { messageId: 'operation-1', position: 1, state: 'waiting' } + }) + const { result } = render() + let outcome: unknown + await act(async () => { + outcome = await result.current.runConversationCommand('compact') + }) + expect(outcome).toEqual({ accepted: true, error: null }) + const parsed = ConversationCommandParams.parse(commandCalls()[0]) + expect(parsed).toMatchObject({ command: 'compact', delivery: 'queue-if-active' }) + expect(parsed.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: 'session-1', + fields: { command: 'compact', delivery: 'queue-if-active' } + }) + ) + }) + + function unsent( + id: string, + overrides: Partial = {} + ): StructuredAgentSessionPendingSend { + return { + clientMessageId: id, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `message ${id}` }] }, + previewUris: [], + queuedAt: 1, + phase: 'sending', + issued: true, + ...overrides + } + } + + it('behind a message still on its way: Send is busy, the message reads Sending, nothing is armed', async () => { + items = [] + answerCommands({ command: 'compact', state: 'completed' }) + pendingSends = [unsent('on-its-way')] + const { result, rerender } = render() + // The composer's send control is Stop while a send is on its way: the existing busy state. + expect(result.current.canStop).toBe(true) + // Its row reads "Sending…" (`NativeChatMessageRow`), from the same outbox. + expect( + structuredAgentSessionDeliveryNotices({ + pending: pendingSends, + agentName: 'Claude', + submissions: [], + startFailures: [] + }).get(agentJournalSubmissionKey('on-its-way')) + ).toEqual({ sending: true }) + let outcome: unknown + await act(async () => { + outcome = await result.current.runConversationCommand('compact') + }) + expect(outcome).toEqual({ accepted: false, error: null }) + // The host has it now: nothing goes out on its own; the next press does. + pendingSends = [] + rerender() + expect(commandCalls()).toHaveLength(0) + expect(result.current.canStop).toBe(false) + await act(async () => { + outcome = await result.current.runConversationCommand('compact') + }) + expect(outcome).toEqual({ accepted: true, error: null }) + expect(commandCalls()).toHaveLength(1) + }) + + it('Stop settles the pending send and leaves no command armed behind it', async () => { + items = [] + answerCommands({ command: 'compact', state: 'completed' }) + pendingSends = [unsent('on-its-way')] + const { result, rerender } = render() + await act(async () => { + expect(await result.current.runConversationCommand('compact')).toEqual({ + accepted: false, + error: null + }) + await result.current.stop() + }) + pendingSends = [] + rerender() + expect(result.current.canStop).toBe(false) + expect(commandCalls()).toHaveLength(0) + await act(async () => { + expect(await result.current.runConversationCommand('compact')).toEqual({ + accepted: true, + error: null + }) + }) + }) + + it('mid-turn, behind a queue send on its way: it reads Sending as a card, and nothing is armed', async () => { + answerCommands({ command: 'compact', state: 'completed' }) + pendingSends = [ + unsent('on-its-way', { + delivery: 'queue-if-active' + }) + ] + const { result } = render() + // Not a transcript bubble mid-turn; the card it is about to become reads as sending. + expect(JSON.stringify(result.current.messages)).not.toContain('message on-its-way') + expect(result.current.queuedMessages.cards).toEqual([ + expect.objectContaining({ + messageId: 'on-its-way', + text: 'message on-its-way', + hold: 'sending' + }) + ]) + await act(async () => { + expect(await result.current.runConversationCommand('compact')).toEqual({ + accepted: false, + error: null + }) + }) + expect(commandCalls()).toHaveLength(0) + }) + + it('an idle send the host has recorded is its transcript row only, never a sending card too', () => { + items = [] + pendingSends = [ + unsent('recorded', { + delivery: 'queue-if-active' + }) + ] + // The host took it straight through: its own submission, unanswered, makes the chat working. + submissions = [ + { + clientMessageId: 'recorded', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 2, + resolvedAt: null, + handoverRecorded: true, + handedOverAt: 3 + } + ] + const { result } = render() + expect(result.current.isWorking).toBe(true) + expect(result.current.queuedMessages.cards).toEqual([]) + }) + + it('/clear right after a Stop kept a send on its way names no Retry it does not show', async () => { + items = [] + pendingSends = [ + unsent('kept', { + delivery: 'queue-if-active' + }) + ] + const { result } = render() + let outcome: unknown + await act(async () => { + outcome = await result.current.runConversationCommand('clear') + }) + expect(outcome).toEqual({ + accepted: false, + error: 'Your earlier message is still being sent. Run /clear once it has gone.', + refusedWhile: 'sending' + }) + }) + + it('/clear with the agent idle behind its own unsent message says it is still being sent', async () => { + items = [] + pendingSends = [unsent('on-its-way')] + const { result, rerender } = render() + let outcome: unknown + await act(async () => { + outcome = await result.current.runConversationCommand('clear') + }) + expect(outcome).toEqual({ + accepted: false, + error: 'Your earlier message is still being sent. Run /clear once it has gone.', + refusedWhile: 'sending' + }) + expect(commandCalls()).toHaveLength(0) + // A failed send returns to the composer and no longer blocks another command. + pendingSends = [] + rerender() + expect(result.current.commandRefusalCauses).toMatchObject({ sending: false, retry: false }) + }) + + it('a recorded message no longer blocks /clear or /compact', async () => { + items = [] + pendingSends = [unsent('recorded', { phase: 'recorded' })] + const { result } = render() + for (const command of ['clear', 'compact'] as const) { + answerCommands({ command, state: 'completed' }) + await act(async () => { + expect(await result.current.runConversationCommand(command)).toEqual({ + accepted: true, + error: null + }) + }) + } + expect(commandCalls()).toHaveLength(2) + expect(result.current.commandRefusalCauses).toMatchObject({ sending: false, retry: false }) + }) + + it('/clear mid-turn is still refused here, and never asks to wait', async () => { + const { result } = render() + await act(async () => { + await result.current.runConversationCommand('clear') + }) + expect(commandCalls()).toHaveLength(0) + items = [] + answerCommands({ command: 'clear', state: 'completed' }) + const idle = render() + await act(async () => { + await idle.result.current.runConversationCommand('clear') + }) + expect(ConversationCommandParams.parse(commandCalls()[0])).not.toHaveProperty('delivery') + }) + + it('a rewind on its way holds /clear and /compact alike, in the same words, and writes nothing', async () => { + items = [] + mocks.rewindPending = true + const { result } = render() + for (const command of ['clear', 'compact'] as const) { + let outcome: unknown + await act(async () => { + outcome = await result.current.runConversationCommand(command) + }) + expect(outcome).toEqual({ + accepted: false, + error: 'Wait for pending work and messages to finish before using this command.' + }) + } + expect(commandCalls()).toHaveLength(0) + }) + + const compactCard = (held: boolean) => ({ + ...draft('compact-1'), + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: '/compact' }], + command: { name: 'compact' as const } + }, + ...(held ? { paused: true as const, pausedReason: 'send_failed' as const } : {}) + }) + + it('a send behind a waiting command card queues, even with follow-ups off', () => { + queuedMessages = [compactCard(false)] + render(false) + expect(mocks.sendArgs.at(-1)?.queue).toEqual({ + capability: 'supported', + enabled: true + }) + }) + + it('a send-failed command card, which the queue skips, does not force a send to queue', () => { + queuedMessages = [compactCard(true)] + render(false) + expect(mocks.sendArgs.at(-1)?.queue).toEqual({ + capability: 'supported', + enabled: false + }) + }) +}) + +describe('a /compact against a host that queues messages but not commands', () => { + beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ]) + }) + + it('mid-turn, is held back here as today, and idle goes out without `delivery`', async () => { + const { result } = render() + await act(async () => { + await result.current.runConversationCommand('compact') + }) + expect(commandCalls()).toHaveLength(0) + items = [] + answerCommands({ command: 'compact', state: 'completed' }) + const idle = render() + await act(async () => { + await idle.result.current.runConversationCommand('compact') + }) + expect(ConversationCommandParams.parse(commandCalls()[0])).not.toHaveProperty('delivery') + }) +}) + +describe('a /compact against a host that holds commands but has its queue dark', () => { + beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY + ]) + }) + + it("is exactly today's: held back mid-turn, and idle goes out without `delivery`", async () => { + const { result } = render() + let outcome: unknown + await act(async () => { + outcome = await result.current.runConversationCommand('compact') + }) + expect(outcome).toEqual({ + accepted: false, + error: "The agent is still working. Run /compact when it's done.", + refusedWhile: 'working' + }) + expect(commandCalls()).toHaveLength(0) + items = [] + answerCommands({ command: 'compact', state: 'completed' }) + const idle = render() + await act(async () => { + await idle.result.current.runConversationCommand('compact') + }) + expect(ConversationCommandParams.parse(commandCalls()[0])).not.toHaveProperty('delivery') + }) +}) + describe('against a host without the capability', () => { beforeEach(() => { setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) @@ -360,7 +740,7 @@ describe('against a host without the capability', () => { it('never asks for queue delivery, whatever the setting says', () => { render() - expect(mocks.outboxArgs.at(-1)?.queue?.capability).toBe('unsupported') + expect(mocks.sendArgs.at(-1)?.queue?.capability).toBe('unsupported') }) it("Stop stays exactly today's conversation Stop — no withdrawQueued key at all", async () => { 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 062de2b6356c..d21cb5b29a97 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,24 +1,19 @@ import { useMemo, useRef } from 'react' -import * as structuredConversationCommands from './structured-conversation-command-send' -import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire' import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' import { useStructuredAgentSessionCommandWrite } from './use-structured-agent-session-command-write' import { structuredAgentSessionNewSendsQueue } from './structured-agent-session-queue-request' -import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { AgentType } from '../../../../shared/agent-status-types' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { takeBackStructuredLaunchPrompts } from '@/lib/structured-agent-session-launch-prompt' +import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client' import { - supportsStructuredAgentSessionPromptCancel, - supportsStructuredAgentSessionQuestionAnswers -} from '@/runtime/structured-agent-session-client' -import { useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' + useStructuredAgentSessionHostQueuesCommands, + useStructuredAgentSessionHostQueuesMessagesState +} from '@/runtime/structured-agent-session-host-capability' import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' -import { - legacyAgentSessionSelectedOptionId, - type AgentSessionPromptResponse -} from '../../../../shared/agent-session-question-answer' +import type { AgentSessionPromptResponse } from '../../../../shared/agent-session-question-answer' +import { respondToStructuredAgentSessionPrompt } from './structured-agent-session-prompt-response' import { pendingStructuredSessionPrompts, type StructuredPromptItem @@ -33,8 +28,11 @@ import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-sess import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage' import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' -import { pendingSendsOutsideQueuedCards } from './structured-agent-session-queued-cards' -import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' +import { structuredConversationCommandRunner } from './structured-conversation-command-send' +import { + commandCardWaiting, + pendingSendsOutsideQueuedCards +} from './structured-agent-session-queued-cards' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' @@ -130,9 +128,12 @@ export function useStructuredAgentSession(args: { mutate }) const prompts = pendingStructuredSessionPrompts(transportState.journalItems) + const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts) + // A send after a command the queue will run goes behind it, even with follow-ups off. // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot // answer: the send must start a turn, after which the card's cancel works. - const queueEnabled = queueFollowUps && !pendingPromptsAllUnanswerableHere(prompts) + const commandWaiting = commandCardWaiting(transportState.queuedMessages) + const queueEnabled = (queueFollowUps || commandWaiting) && !promptsUnanswerableHere const queue = useMemo( () => ({ capability: queueCapability, enabled: queueEnabled }), [queueCapability, queueEnabled] @@ -169,9 +170,8 @@ export function useStructuredAgentSession(args: { const { pending } = sends const commandWrite = useStructuredAgentSessionCommandWrite(sessionId, write) - // Out and unsettled: the chat takes no other send, and Stop stops it. const sending = pending.some((entry) => entry.phase === 'sending') - // What the host refuses a conversation command or a rewind behind. + // What the host refuses a rewind behind; a command's own hold is the runner's below. const conversationBusy = Boolean( transportState.turnId || prompts.length || @@ -214,6 +214,8 @@ export function useStructuredAgentSession(args: { enabled: queueCapability === 'supported' && transportState.fence !== null, hasPendingPrompt: prompts.length > 0, isWorking, + // Hidden from the transcript, a queue send on its way reads as sending among the cards. + sending: pending, composerScopeKey, mutate }) @@ -221,15 +223,19 @@ export function useStructuredAgentSession(args: { epoch: state.epoch, rewind, conversationCommands, - runConversationCommand: (command: AgentSessionConversationCommand) => - structuredConversationCommands.sendStructuredConversationCommand({ - command, - agentName: structuredAgentLabel(agent), - pending: commandPending, - blocked: conversationBusy || rewind.blockedRef.current, - startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items), - send: commandWrite - }), + ...structuredConversationCommandRunner({ + agentName: structuredAgentLabel(agent), + pending: commandPending, + // A /compact waits in line only where its card renders. + commandsWait: + useStructuredAgentSessionHostQueuesCommands(target) && queueCapability === 'supported', + chat: transportState, + prompts, + rewindInFlight: rewind.blockedRef, + sends: pending, + items: () => stateRef.current.items, + send: commandWrite + }), journalItems: transcriptItems, /** The host's newest turn record, which places a live turn whose record is not loaded. */ latestTurn: transportState.latestTurn, @@ -296,32 +302,8 @@ export function useStructuredAgentSession(args: { scope: 'background-tasks', ...(taskId ? { taskId } : {}) }), - respond: async (item: StructuredPromptItem, response: AgentSessionPromptResponse) => { - const promptTarget = { itemId: item.itemId, expectedRevision: item.revision } - let fields: Record - if (response.kind === 'option') { - fields = { ...promptTarget, optionId: response.optionId } - } else if (await supportsStructuredAgentSessionQuestionAnswers(target)) { - // Negotiated before mutate fingerprints the call: older hosts reject the strict field. - fields = { ...promptTarget, answers: response.answers } - } else { - const optionId = - item.body.kind === 'question' - ? legacyAgentSessionSelectedOptionId(item.body, response.answers) - : null - if (optionId === null) { - return null - } - fields = { ...promptTarget, optionId } - } - return mutate( - item.body.kind === 'approval' - ? 'agentSession.respondToApproval' - : 'agentSession.respondToQuestion', - `agentSession.respondTo:${item.body.kind}`, - fields - ) - }, + respond: (item: StructuredPromptItem, response: AgentSessionPromptResponse) => + respondToStructuredAgentSessionPrompt({ item, response, target, mutate }), optionSnapshot, optionSurface, sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f47ca0c96a2b..e399a57ee8dc 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2879,6 +2879,7 @@ "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", + "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}.", "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", "claudeSystemNotSignedIn": "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.", "cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", @@ -2920,6 +2921,7 @@ "providerStartFailed": "{{agent}} stopped before it finished starting.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", "runCommandAgain": "Run /{{command}} again.", + "runCommandWhenDone": "Run /{{command}} when it's done.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", @@ -3013,6 +3015,7 @@ "writeNotice": { "agentRefused": "The agent turned this down.", "agentStarting": "The agent is still starting.", + "agentStillWorking": "The agent is still working.", "answerFirst": "Answer the question or approval first.", "attachmentExpired": "This attachment expired.", "backgroundTasksRunning": "Background tasks are still running.", @@ -3020,8 +3023,14 @@ "capacity": "Orca on the computer running this chat has hit a request limit. Update Orca there, then try again.", "chatNotFound": "Orca can't find this chat.", "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", + "clearAfterAnswer": "Answer the agent's question or approval, then run /clear.", + "clearAfterRetry": "Retry your earlier message, then run /clear.", + "clearAfterSending": "Your earlier message is still being sent. Run /clear once it has gone.", "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", + "compactAfterAnswer": "Answer the agent's question or approval, then run /compact.", + "compactAfterRetry": "Retry your earlier message, then run /compact.", + "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone.", "conversationCleared": "This conversation has been cleared.", "goalsUnsupported": "This agent doesn't support goals.", "historyUnavailable": "Orca couldn't open this chat's history right now.", @@ -3053,6 +3062,8 @@ "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", + "runClearWhenDone": "Run /clear when it's done.", + "runCompactWhenDone": "Run /compact when it's done.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d56b5ad2b560..c31c67c6dde0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18067,7 +18067,9 @@ "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", "argumentsPositionalPrompt": "Saved Arguments include a prompt.", - "editSavedArguments": "Edit them in Settings > Agents > Arguments." + "editSavedArguments": "Edit them in Settings > Agents > Arguments.", + "runCommandWhenDone": "Run /{{command}} when it's done.", + "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", @@ -18129,7 +18131,16 @@ "recordUnreadable": "Orca couldn't read this chat's saved state.", "chatNotFound": "Orca can't find this chat.", "startNewChat": "Start a new chat to continue.", - "tryAgain": "Try again." + "tryAgain": "Try again.", + "agentStillWorking": "The agent is still working.", + "runClearWhenDone": "Run /clear when it's done.", + "clearAfterAnswer": "Answer the agent's question or approval, then run /clear.", + "runCompactWhenDone": "Run /compact when it's done.", + "compactAfterAnswer": "Answer the agent's question or approval, then run /compact.", + "clearAfterRetry": "Retry your earlier message, then run /clear.", + "compactAfterRetry": "Retry your earlier message, then run /compact.", + "clearAfterSending": "Your earlier message is still being sent. Run /clear once it has gone.", + "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone." }, "taskList": { "title": "Tasks", @@ -18619,7 +18630,8 @@ "sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?", "sendConfirmClear": "Clear queue", "sendConfirmSend": "Send message", - "from": "From {{names}}" + "from": "From {{names}}", + "pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes." }, "thought": "Thought", "thoughtForDuration": "Thought for {{duration}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a914e04dbc26..3c421443e5ac 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17845,6 +17845,8 @@ "providerRetryNumberOf": "Reintento {{attempt}} de {{maxRetries}}.", "providerRetryLastError": "Último error: {{detail}}.", "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}.", + "runCommandWhenDone": "Ejecute /{{command}} cuando termine.", + "commandAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /{{command}}.", "sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores." }, "writeNotice": { @@ -17907,7 +17909,16 @@ "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", "chatNotFound": "Orca no encuentra este chat.", "startNewChat": "Inicie un chat nuevo para continuar.", - "tryAgain": "Vuelva a intentarlo." + "tryAgain": "Vuelva a intentarlo.", + "agentStillWorking": "El agente todavía está trabajando.", + "runClearWhenDone": "Ejecute /clear cuando termine.", + "clearAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /clear.", + "runCompactWhenDone": "Ejecute /compact cuando termine.", + "compactAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /compact.", + "clearAfterRetry": "Reintente su mensaje anterior y luego ejecute /clear.", + "compactAfterRetry": "Reintente su mensaje anterior y luego ejecute /compact.", + "clearAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /clear cuando se haya enviado.", + "compactAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /compact cuando se haya enviado." }, "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", @@ -18230,7 +18241,8 @@ "sendConfirmBody_other": "Estás a punto de enviar un mensaje. ¿Quieres borrar los {{count}} mensajes que había en cola?", "sendConfirmClear": "Vaciar cola", "sendConfirmSend": "Enviar mensaje", - "from": "De {{names}}" + "from": "De {{names}}", + "pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar." }, "thought": "Pensó", "thoughtForDuration": "Pensó durante {{duration}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 7bf8eb683a59..2e61dcc2320a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17946,6 +17946,8 @@ "providerRetryNumberOf": "Nouvelle tentative {{attempt}} sur {{maxRetries}}.", "providerRetryLastError": "Dernière erreur : {{detail}}.", "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}.", + "runCommandWhenDone": "Lancez /{{command}} quand il aura terminé.", + "commandAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /{{command}}.", "sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents." }, "writeNotice": { @@ -18008,7 +18010,16 @@ "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", "chatNotFound": "Orca ne trouve pas ce chat.", "startNewChat": "Démarrez un nouveau chat pour continuer.", - "tryAgain": "Réessayez." + "tryAgain": "Réessayez.", + "agentStillWorking": "L'agent est encore en train de travailler.", + "runClearWhenDone": "Lancez /clear quand il aura terminé.", + "clearAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /clear.", + "runCompactWhenDone": "Lancez /compact quand il aura terminé.", + "compactAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /compact.", + "clearAfterRetry": "Réessayez votre message précédent, puis lancez /clear.", + "compactAfterRetry": "Réessayez votre message précédent, puis lancez /compact.", + "clearAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /clear une fois qu'il est parti.", + "compactAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /compact une fois qu'il est parti." }, "composer": { "imageUnsupported": "Le collage d'image n'est pas pris en charge pour cet agent.", @@ -18467,7 +18478,8 @@ "sendConfirmBody_other": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer les {{count}} messages déjà en file d'attente ?", "sendConfirmClear": "Vider la file d'attente", "sendConfirmSend": "Envoyer le message", - "from": "De {{names}}" + "from": "De {{names}}", + "pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer." }, "thought": "A réfléchi", "thoughtForDuration": "A réfléchi pendant {{duration}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5096ff9c7f65..4fd314a04a49 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17880,6 +17880,8 @@ "providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。", "providerRetryLastError": "直近のエラー: {{detail}}。", "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。", + "runCommandWhenDone": "終わったら /{{command}} を実行してください。", + "commandAfterAnswer": "Agent の質問または承認に回答してから、/{{command}} を実行してください。", "sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。" }, "writeNotice": { @@ -17942,7 +17944,16 @@ "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", "chatNotFound": "Orca はこのチャットを見つけられません。", "startNewChat": "続けるには、新しいチャットを開始してください。", - "tryAgain": "もう一度お試しください。" + "tryAgain": "もう一度お試しください。", + "agentStillWorking": "Agent はまだ作業中です。", + "runClearWhenDone": "終わったら /clear を実行してください。", + "clearAfterAnswer": "Agent の質問または承認に回答してから、/clear を実行してください。", + "runCompactWhenDone": "終わったら /compact を実行してください。", + "compactAfterAnswer": "Agent の質問または承認に回答してから、/compact を実行してください。", + "clearAfterRetry": "以前のメッセージを再試行してから、/clear を実行してください。", + "compactAfterRetry": "以前のメッセージを再試行してから、/compact を実行してください。", + "clearAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /clear を実行してください。", + "compactAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /compact を実行してください。" }, "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", @@ -18401,7 +18412,8 @@ "sendConfirmBody_other": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", "sendConfirmClear": "キューを消去", "sendConfirmSend": "メッセージを送信", - "from": "送信元: {{names}}" + "from": "送信元: {{names}}", + "pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。" }, "thought": "考えました", "thoughtForDuration": "{{duration}} 考えました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3b8312655bea..85b2081b1865 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17880,6 +17880,8 @@ "providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.", "providerRetryLastError": "마지막 오류: {{detail}}.", "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다.", + "runCommandWhenDone": "작업이 끝나면 /{{command}}을(를) 실행하세요.", + "commandAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /{{command}}을(를) 실행하세요.", "sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다." }, "writeNotice": { @@ -17942,7 +17944,16 @@ "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", "chatNotFound": "Orca에서 이 채팅을 찾을 수 없습니다.", "startNewChat": "계속하려면 새 채팅을 시작하세요.", - "tryAgain": "다시 시도하세요." + "tryAgain": "다시 시도하세요.", + "agentStillWorking": "에이전트가 아직 작업 중입니다.", + "runClearWhenDone": "작업이 끝나면 /clear를 실행하세요.", + "clearAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /clear를 실행하세요.", + "runCompactWhenDone": "작업이 끝나면 /compact를 실행하세요.", + "compactAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /compact를 실행하세요.", + "clearAfterRetry": "이전 메시지를 다시 시도한 다음 /clear를 실행하세요.", + "compactAfterRetry": "이전 메시지를 다시 시도한 다음 /compact를 실행하세요.", + "clearAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /clear를 실행하세요.", + "compactAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /compact를 실행하세요." }, "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", @@ -18401,7 +18412,8 @@ "sendConfirmBody_other": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", "sendConfirmClear": "대기열 지우기", "sendConfirmSend": "메시지 보내기", - "from": "보낸 사람: {{names}}" + "from": "보낸 사람: {{names}}", + "pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요." }, "thought": "생각함", "thoughtForDuration": "{{duration}} 동안 생각함", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4df85264d1d8..e3a3d0905363 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17845,6 +17845,8 @@ "providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。", "providerRetryLastError": "上次错误:{{detail}}。", "previousExitUnverifiable": "无法停止之前的 {{agent}}。", + "runCommandWhenDone": "完成后再运行 /{{command}}。", + "commandAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /{{command}}。", "sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。" }, "writeNotice": { @@ -17907,7 +17909,16 @@ "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", "chatNotFound": "Orca 找不到此聊天。", "startNewChat": "请开始新的聊天以继续。", - "tryAgain": "请重试。" + "tryAgain": "请重试。", + "agentStillWorking": "智能体仍在工作。", + "runClearWhenDone": "完成后再运行 /clear。", + "clearAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /clear。", + "runCompactWhenDone": "完成后再运行 /compact。", + "compactAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /compact。", + "clearAfterRetry": "请先重试您之前的消息,然后再运行 /clear。", + "compactAfterRetry": "请先重试您之前的消息,然后再运行 /compact。", + "clearAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /clear。", + "compactAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /compact。" }, "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", @@ -18366,7 +18377,8 @@ "sendConfirmBody_other": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", "sendConfirmClear": "清空队列", "sendConfirmSend": "发送消息", - "from": "来自 {{names}}" + "from": "来自 {{names}}", + "pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。" }, "thought": "已思考", "thoughtForDuration": "思考了 {{duration}}", 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 0f91cb82586f..cf1d8c4baa22 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_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, type RuntimeCapability @@ -117,3 +118,12 @@ export function useStructuredAgentSessionHostQueuesMessagesState( AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY ) } + +/** Whether the host holds a /compact sent while the agent works as a queued card: only then may + * a client send the command's `delivery`, and only where it also renders the queue's cards. */ +export function useStructuredAgentSessionHostQueuesCommands(target: RuntimeClientTarget): boolean { + return useStructuredAgentSessionHostCapability( + target, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY + ) +} diff --git a/src/shared/agent-session-command-refusal-words.ts b/src/shared/agent-session-command-refusal-words.ts new file mode 100644 index 000000000000..ec85fe3b9947 --- /dev/null +++ b/src/shared/agent-session-command-refusal-words.ts @@ -0,0 +1,49 @@ +// A conversation command refused for a reason the person can act on: said as that refusal is +// everywhere, so a command's row names what to wait for rather than "try it again". + +import type { AgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureSay } from './agent-session-failure-copy' +import type { AgentSessionConversationCommand } from './agent-session-conversation-command' +import { joinSentences } from './sentence-joining' + +type ReasonWords = ( + say: AgentSessionFailureSay, + command: AgentSessionConversationCommand | undefined +) => string | undefined + +const STILL_WORKING: ReasonWords = (say, command) => + command + ? joinSentences([say('agentStillWorking'), say('runCommandWhenDone', { command })]) + : undefined + +/** A wait the person ends: nothing runs the command after it, so they run it again. */ +const WAIT_THEN_RUN_AGAIN = + ( + cause: 'backgroundTasksRunning' | 'agentStarting', + wait: 'waitForBackgroundTasks' | 'waitForStart' + ): ReasonWords => + (say, command) => + joinSentences([ + say(cause), + say(wait), + ...(command ? [say('runCommandAgain', { command })] : []) + ]) + +const WORDS_BY_REASON: Partial> = { + backgroundTasksRunning: WAIT_THEN_RUN_AGAIN('backgroundTasksRunning', 'waitForBackgroundTasks'), + handoffInFlight: WAIT_THEN_RUN_AGAIN('agentStarting', 'waitForStart'), + turnActive: STILL_WORKING, + messagesUnsettled: STILL_WORKING, + promptPending: (say, command) => (command ? say('commandAfterAnswer', { command }) : undefined) +} + +/** Undefined for a reason with no words of its own: the command's generic sentence says it. */ +export function commandRefusedByReason( + say: AgentSessionFailureSay, + { command }: { command?: AgentSessionConversationCommand }, + fact: AgentSessionFailureFact +): string | undefined { + const details = fact.refusal?.details + const reason = details && 'reason' in details ? details.reason : undefined + return reason === undefined ? undefined : WORDS_BY_REASON[reason]?.(say, command) +} diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts index d1e5a2671fad..b696d0515317 100644 --- a/src/shared/agent-session-conversation-command.ts +++ b/src/shared/agent-session-conversation-command.ts @@ -1,5 +1,6 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureSentence } from './agent-session-failure-words' +import type { AgentSessionQueuedSendReceipt } from './agent-session-wire' export type AgentSessionConversationCommand = 'clear' | 'compact' @@ -10,6 +11,9 @@ export type AgentSessionConversationCommandResult = { command: AgentSessionConversationCommand state: 'completed' | 'unknown' replacementSessionId?: string + /** A /compact the host holds as a card behind work in flight, with `state: 'completed'`: the + * card is its one surface from here. Only clients that sent `delivery` receive it. */ + queued?: AgentSessionQueuedSendReceipt } & /** `error`: a sentence for a person; released clients print it as it is. */ ( diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index cd51a03f9133..21deeff0e09d 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -5,6 +5,11 @@ export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.' export const QUIT_TERMINAL_AGENT = 'Quit that agent to continue the chat here.' export const START_NEW_CHAT = 'Start a new chat to continue.' +export const BACKGROUND_TASKS_RUNNING = 'Background tasks are still running.' +export const WAIT_FOR_BACKGROUND_TASKS = 'Wait for the background tasks to finish.' +export const AGENT_STARTING = 'The agent is still starting.' +export const WAIT_FOR_START = 'Wait for the agent to finish starting.' +export const AGENT_STILL_WORKING = 'The agent is still working.' /** Every piece a failure sentence is made of, whole so desktop can translate each on its own. * `{{agent}}` is the agent's name or `theAgent`, `{{command}}` a conversation command's name; the @@ -88,6 +93,14 @@ export const AGENT_SESSION_FAILURE_COPY = { notDeliveredSendAgain: 'This message was not delivered. Send it again to continue.', commandRefused: "This command didn't run.", commandRefusedTryAgain: "This command didn't run. Try it again.", + // What kept a command from running, in the words its refusal has everywhere. + backgroundTasksRunning: BACKGROUND_TASKS_RUNNING, + waitForBackgroundTasks: WAIT_FOR_BACKGROUND_TASKS, + agentStarting: AGENT_STARTING, + waitForStart: WAIT_FOR_START, + agentStillWorking: AGENT_STILL_WORKING, + runCommandWhenDone: "Run /{{command}} when it's done.", + commandAfterAnswer: "Answer the agent's question or approval, then run /{{command}}.", compactionFailed: 'Compaction failed.', compactionFailedQuoted: 'Compaction failed: {{detail}}.', compactionUnconfirmed: 'Compaction completion is unconfirmed.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index ca219c0c6f35..fbf1a61c07b0 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -27,6 +27,7 @@ import { } from './agent-session-failure-copy' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words' +import { commandRefusedByReason } from './agent-session-command-refusal-words' import { joinSentences } from './sentence-joining' import { DISPATCH_REJECTED_CANCELLED, @@ -243,8 +244,9 @@ const FAILURE_SENTENCES = { hostRestarted: (_context, _fact, _surface, say) => say('hostRestarted'), notDelivered: ({ retryControl }, _fact, _surface, say) => say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'), - commandRefused: ({ retryControl }, _fact, _surface, say) => - say(retryControl ? 'commandRefused' : 'commandRefusedTryAgain'), + commandRefused: (context, fact, _surface, say) => + commandRefusedByReason(say, context, fact) ?? + say(context.retryControl ? 'commandRefused' : 'commandRefusedTryAgain'), compactionFailed: (_context, fact, _surface, say) => quotingPersonDetail(say, 'compactionFailed', 'compactionFailedQuoted', fact.detail), compactionUnconfirmed: (_context, _fact, _surface, say) => say('compactionUnconfirmed'), diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index ca9ebfb67c39..b1257fb5c17c 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -44,6 +44,8 @@ const WRITES: AgentSessionWriteKind[] = [ 'answer', 'option', 'command', + 'clear', + 'compact', 'goal' ] const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.' @@ -92,6 +94,8 @@ const NOT_DONE: Record = answer: 'notDoneAnswer', option: 'notDoneOption', command: 'notDoneCommand', + clear: 'notDoneCommand', + compact: 'notDoneCommand', goal: 'notDoneGoal' } @@ -453,8 +457,17 @@ describe('the notice for every reason a host names', () => { write !== 'read-history' const saysNotDone = write === 'read-history' && failure.code === 'agent_session_journal_unreadable' + // "Run /clear when it's done" already says the command has yet to happen. + const clearWhileWorking = ( + [ + 'runClearWhenDone', + 'clearAfterAnswer', + 'runCompactWhenDone', + 'compactAfterAnswer' + ] as const + ).some((sentence) => parts.includes(sentence)) expect(notDone, cell).toEqual( - answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]] + answeredAway || unsupported || saysNotDone || clearWhileWorking ? [] : [NOT_DONE[write]] ) } }) @@ -617,6 +630,57 @@ describe('a chat whose history the host could not open', () => { }) }) +describe('a /clear or /compact refused while the agent works', () => { + const refused = (reason: 'turnActive' | 'messagesUnsettled' | 'promptPending') => + ({ kind: 'refused', code: 'agent_session_operation_invalid', details: { reason } }) as const + + it('says one plain sentence of what the person sees and can do, whichever reason', () => { + for (const reason of ['turnActive', 'messagesUnsettled'] as const) { + expect( + agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts(refused(reason), 'clear')) + ).toBe("The agent is still working. Run /clear when it's done.") + } + expect( + agentSessionWriteNoticeEnglish( + agentSessionWriteNoticeParts(refused('promptPending'), 'clear') + ) + ).toBe("Answer the agent's question or approval, then run /clear.") + }) + + it('says the same for a /compact, which a host without the queue still refuses', () => { + for (const reason of ['turnActive', 'messagesUnsettled'] as const) { + expect( + agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts(refused(reason), 'compact')) + ).toBe("The agent is still working. Run /compact when it's done.") + } + expect( + agentSessionWriteNoticeEnglish( + agentSessionWriteNoticeParts(refused('promptPending'), 'compact') + ) + ).toBe("Answer the agent's question or approval, then run /compact.") + }) + + it('leaves a command this build does not know its own words', () => { + expect(agentSessionWriteNoticeParts(refused('turnActive'), 'command')).toEqual([ + 'turnActive', + 'notDoneCommand', + 'waitForTurn' + ]) + }) + + it('is the write a /clear or /compact is, and only those', () => { + expect( + agentSessionWriteKindForMethod('agentSession.conversationCommand', { command: 'clear' }) + ).toBe('clear') + expect( + agentSessionWriteKindForMethod('agentSession.conversationCommand', { command: 'compact' }) + ).toBe('compact') + expect( + agentSessionWriteKindForMethod('agentSession.conversationCommand', { command: 'rewind' }) + ).toBe('command') + }) +}) + // For a line that already says what did not happen and shows its own Retry, such as a chat that // could not start: the Retry is the step for retrying and for sending again, and only for those. describe('agentSessionRefusalCauseParts', () => { diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index c95f175f0cb0..f81e1b51a901 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -46,9 +46,28 @@ const NOT_DONE: Record = answer: 'notDoneAnswer', option: 'notDoneOption', command: 'notDoneCommand', + clear: 'notDoneCommand', + compact: 'notDoneCommand', goal: 'notDoneGoal' } +/** A /clear or /compact refused because the agent is working: one plain sentence for every + * reason that is, saying only what the person sees and can do. */ +const COMMAND_WHILE_WORKING: Partial< + Record>> +> = { + clear: { + turnActive: ['agentStillWorking', 'runClearWhenDone'], + messagesUnsettled: ['agentStillWorking', 'runClearWhenDone'], + promptPending: ['clearAfterAnswer'] + }, + compact: { + turnActive: ['agentStillWorking', 'runCompactWhenDone'], + messagesUnsettled: ['agentStillWorking', 'runCompactWhenDone'], + promptPending: ['compactAfterAnswer'] + } +} + /** That the write did not happen, for one that a second attempt can carry out. Only the phone says * how: its message goes back to the composer and it has no Retry control. Everywhere else the * control that sent the write is the way to try again. */ @@ -94,6 +113,13 @@ function reasonParts( } ] } + const commandWhileWorking = + failure.code === 'agent_session_operation_invalid' + ? COMMAND_WHILE_WORKING[write]?.[failure.details?.reason ?? ''] + : undefined + if (commandWhileWorking) { + return commandWhileWorking + } const words = agentSessionRefusalReasonWords(failure) if (!words || 'words' in words) { return undefined diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index 2a3124ae73bf..20dc98563901 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -31,6 +31,9 @@ export type AgentSessionWriteKind = | 'answer' | 'option' | 'command' + /** A /clear or /compact: refused while the agent works, in words of its own. */ + | 'clear' + | 'compact' | 'goal' /** The kind of write an `agentSession.*` call stands for. */ @@ -57,6 +60,12 @@ export function agentSessionWriteKindForMethod( if (fingerprintMethod === 'agentSession.threadGoal') { return 'goal' } + if ( + fingerprintMethod === 'agentSession.conversationCommand' && + (fields.command === 'clear' || fields.command === 'compact') + ) { + return fields.command + } return 'command' } diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 084f23eb1f7f..64ff3416558c 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -2,9 +2,14 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import { + AGENT_STARTING, + AGENT_STILL_WORKING, + BACKGROUND_TASKS_RUNNING, QUIT_TERMINAL_AGENT, START_NEW_CHAT, - TERMINAL_AGENT_HOLDS_CHAT + TERMINAL_AGENT_HOLDS_CHAT, + WAIT_FOR_BACKGROUND_TASKS, + WAIT_FOR_START } from './agent-session-failure-copy' import type { AgentSessionFailureSurface, @@ -52,16 +57,25 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { clearUnfinished: "The last /clear didn't finish.", commandRunning: 'A /compact or /clear is still running.', waitForCommand: 'Wait for the /compact or /clear to finish.', - agentStarting: 'The agent is still starting.', - waitForStart: 'Wait for the agent to finish starting.', + agentStarting: AGENT_STARTING, + waitForStart: WAIT_FOR_START, turnActive: 'The agent is still responding.', waitForTurn: 'Wait for the agent to finish responding, or stop it.', promptPending: 'The agent is waiting for an answer to a question or approval.', answerFirst: 'Answer the question or approval first.', - backgroundTasksRunning: 'Background tasks are still running.', - waitForBackgroundTasks: 'Wait for the background tasks to finish.', + backgroundTasksRunning: BACKGROUND_TASKS_RUNNING, + waitForBackgroundTasks: WAIT_FOR_BACKGROUND_TASKS, messagesUnsettled: "A message you sent earlier isn't confirmed yet.", settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', + agentStillWorking: AGENT_STILL_WORKING, + runClearWhenDone: "Run /clear when it's done.", + clearAfterAnswer: "Answer the agent's question or approval, then run /clear.", + runCompactWhenDone: "Run /compact when it's done.", + compactAfterAnswer: "Answer the agent's question or approval, then run /compact.", + clearAfterRetry: 'Retry your earlier message, then run /clear.', + compactAfterRetry: 'Retry your earlier message, then run /compact.', + clearAfterSending: 'Your earlier message is still being sent. Run /clear once it has gone.', + compactAfterSending: 'Your earlier message is still being sent. Run /compact once it has gone.', queueTooLarge: 'Too much text is waiting in the queue.', shrinkQueue: 'Delete a queued message, or wait for one to go through, then try again.', optionRejected: "The agent didn't accept this setting.", diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4dd12da66ea8..5575a3e5b47c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -47,17 +47,7 @@ export { import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' -import { - SKILL_BUNDLE_INSTALL_CAPABILITY, - SKILL_DELETE_CAPABILITY, - SKILL_INSTALL_CAPABILITY, - SKILL_INSTALL_CANCEL_CAPABILITY, - SKILL_INSTALL_PROGRESS_CAPABILITY, - SKILL_INSTALL_PROVIDERS_CAPABILITY, - SKILL_INSTALL_RESULT_V2_CAPABILITY, - SKILL_MANAGEMENT_CAPABILITY, - SKILL_UPLOAD_CAPABILITY -} from './skill-install-capability' +import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability' import { AGENT_LAUNCH_RUNTIME_CAPABILITIES, AGENT_LAUNCH_RUNTIME_CAPABILITY @@ -248,6 +238,12 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const +// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects +// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued +// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too: +// the card is the only place the waiting command shows. +export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY = + 'agent-session.queued-commands.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = @@ -397,6 +393,7 @@ export const RUNTIME_CAPABILITIES = [ // turn starts, so a client may gate on either. AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY, ...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES, ...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES, @@ -419,15 +416,7 @@ export const RUNTIME_CAPABILITIES = [ ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY, DATA_ACCOUNT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY, - SKILL_INSTALL_CAPABILITY, - SKILL_BUNDLE_INSTALL_CAPABILITY, - SKILL_INSTALL_CANCEL_CAPABILITY, - SKILL_INSTALL_PROGRESS_CAPABILITY, - SKILL_INSTALL_RESULT_V2_CAPABILITY, - SKILL_UPLOAD_CAPABILITY, - SKILL_MANAGEMENT_CAPABILITY, - SKILL_INSTALL_PROVIDERS_CAPABILITY, - SKILL_DELETE_CAPABILITY, + ...SKILL_RUNTIME_CAPABILITIES, AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, diff --git a/src/shared/returned-draft-text.test.ts b/src/shared/returned-draft-text.test.ts index 79f27507cde0..9899d7912ca5 100644 --- a/src/shared/returned-draft-text.test.ts +++ b/src/shared/returned-draft-text.test.ts @@ -20,4 +20,15 @@ describe('appendReturnedDraftText', () => { it('still appends text that only ends a line of the draft', () => { expect(appendReturnedDraftText('say ping', 'ping')).toBe('say ping\n\nping') }) + + it('a lone conversation command gives way to the message handed back, which it could not carry', () => { + expect(appendReturnedDraftText('/compact', 'my message')).toBe('my message') + expect(appendReturnedDraftText(' /clear\n', 'my message')).toBe('my message') + // Anything more than the command is the person's text, and stays. + expect(appendReturnedDraftText('/compact now', 'my message')).toBe('/compact now\n\nmy message') + expect(appendReturnedDraftText('a/b', 'my message')).toBe('a/b\n\nmy message') + // A slash word that is not a conversation command is the person's text too. + expect(appendReturnedDraftText('/clearly', 'my message')).toBe('/clearly\n\nmy message') + expect(appendReturnedDraftText('/tmp', 'my message')).toBe('/tmp\n\nmy message') + }) }) diff --git a/src/shared/returned-draft-text.ts b/src/shared/returned-draft-text.ts index 4dc67520d7fa..b60388fe84f8 100644 --- a/src/shared/returned-draft-text.ts +++ b/src/shared/returned-draft-text.ts @@ -1,7 +1,11 @@ +import { isLoneStructuredAgentSessionConversationCommand } from './structured-agent-session-composer' + /** * Puts text a chat handed back after what the composer already holds, a blank line apart. It never - * replaces typed text (a whitespace-only draft counts as empty), and adds nothing when the draft - * already ends with that text, so handing the same text back twice leaves one copy. + * replaces typed text (a whitespace-only draft counts as empty), except a lone conversation command + * (`/compact`, `/clear`), which takes no text after it and was never sent: the message takes its place, so + * neither is left unusable. It adds nothing when the draft already ends with that text, so handing + * the same text back twice leaves one copy. */ export function appendReturnedDraftText(draft: string, text: string): string { const returned = text.trimEnd() @@ -9,7 +13,7 @@ export function appendReturnedDraftText(draft: string, text: string): string { return draft } const kept = draft.trimEnd() - if (kept === '') { + if (kept === '' || isLoneStructuredAgentSessionConversationCommand(kept)) { return text } if (kept === returned || kept.endsWith(`\n\n${returned}`)) { diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index d3ed6283c48a..bf5095ee057b 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -306,7 +306,12 @@ export const ModelCatalogParams = z.strictObject({ export const ConversationCommandParams = z .object({ envelope: MutationEnvelope, - command: z.enum(['clear', 'compact']) + command: z.enum(['clear', 'compact']), + /** A /compact while the agent is working waits as a host-held card, like a queued send. + * Strict object, so an older host refuses it: clients send it only when + * `agent-session.queued-commands.v1` is advertised. A /clear never waits: its operation + * fingerprints no `delivery`, so one sent with it is refused as a conflict. */ + delivery: z.literal('queue-if-active').optional() }) .strict() diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts index 300bb07564bd..a03af0295821 100644 --- a/src/shared/skill-install-capability.ts +++ b/src/shared/skill-install-capability.ts @@ -20,3 +20,15 @@ export const SKILL_DELETE_CAPABILITY = 'skills.delete.v1' as const export const SKILL_DELETE_UPDATE_REQUIRED_MESSAGE = 'Update Orca on the selected machine to delete skills.' + +export const SKILL_RUNTIME_CAPABILITIES = [ + SKILL_INSTALL_CAPABILITY, + SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_INSTALL_CANCEL_CAPABILITY, + SKILL_INSTALL_PROGRESS_CAPABILITY, + SKILL_INSTALL_RESULT_V2_CAPABILITY, + SKILL_UPLOAD_CAPABILITY, + SKILL_MANAGEMENT_CAPABILITY, + SKILL_INSTALL_PROVIDERS_CAPABILITY, + SKILL_DELETE_CAPABILITY +] as const diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 5076e6023d14..b6ba64170e1a 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -69,64 +69,56 @@ describe('structured agent session event coalescer', () => { expect(events[0]).toMatchObject({ activity: null }) }) - it('preserves a queued-message list when later coalesced frames omit it', () => { + it('delivers each queue list at once, in order, each with the pause it was published with', () => { 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 - } - ] + const draft = { + 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.push({ ...batch(1), queuedMessages: [draft], queuePause: { reason: 'stopped' } }) + coalescer.push({ ...batch(2), queuedMessages: [], queuePause: null }) + // A later frame with no list leaves the delivered ones as they are. + coalescer.push(batch(3)) coalescer.flush() - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ queuedMessages: list }) + expect(events).toHaveLength(3) + expect(events[0]).toMatchObject({ + queuedMessages: [draft], + queuePause: { reason: 'stopped' } + }) + expect(events[1]).toMatchObject({ queuedMessages: [], queuePause: null }) + expect(events[2]).not.toHaveProperty('queuedMessages') }) - it('keeps the latest queued-message list, an emptied one included', () => { + it('passes a queue list through at once, with anything it held merged ahead of it', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) - + coalescer.push(batch(1)) coalescer.push({ - ...batch(1), + ...batch(2), queuedMessages: [ { - messageId: 'draft-1', + messageId: 'card-1', position: 1, - body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, - state: 'waiting' as const + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'queued' }] }, + state: 'waiting' } - ] + ], + queuePause: null }) - 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: 'stopped' } }) - coalescer.push(batch(2)) - coalescer.flush() - expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'stopped' } }) - - coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) - coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) - coalescer.flush() - expect(events[1]).toMatchObject({ queuePause: { reason: 'stopped' } }) + // No wait for the coalescing delay: the card lands with the frame that carries it. + expect(events.map((event) => event.type === 'batch' && event.batch.cursor.sequence)).toEqual([ + 1, 2 + ]) + expect(events[1]).toMatchObject({ queuedMessages: [{ messageId: 'card-1' }] }) + coalescer.dispose() }) - it("keeps the queue's next card with its list when a token batch merges into it", () => { + it("delivers the queue's next card with its list, ahead of a later token batch", () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) coalescer.push({ ...batch(1), queuedMessages: [], nextQueuedMessageId: 'draft-1' }) @@ -144,9 +136,11 @@ describe('structured agent session event coalescer', () => { const tokens = batch(2) coalescer.push({ ...tokens, batch: { ...tokens.batch, items: [token] } }) coalescer.flush() - expect(events).toHaveLength(1) + expect(events).toHaveLength(2) expect(events[0]).toMatchObject({ nextQueuedMessageId: 'draft-1' }) + expect(events[1]).not.toHaveProperty('queuedMessages') }) + it('keeps the latest turn a coalesced frame carried, and a null one as an answer', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index a19e584af9b8..f80658838dc1 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -6,19 +6,13 @@ export const STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS = 48 function bypassCoalescing(event: AgentSessionSubscribeEvent): boolean { return ( event.type !== 'batch' || + // A queue list lands at once: held, the card would be missing for a moment after the send's + // own "Sending…" card went with its queued answer. + event.queuedMessages !== undefined || event.batch.items.some((item) => item.body.kind !== 'message' || item.body.role !== 'assistant') ) } -/** The list and what rides with it. */ -function queuePublicationOf(event: Extract) { - return { - queuedMessages: event.queuedMessages, - queuePause: event.queuePause ?? null, - nextQueuedMessageId: event.nextQueuedMessageId ?? null - } -} - function mergeBatch( left: Extract, right: Extract @@ -40,13 +34,6 @@ 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 - ? queuePublicationOf(right) - : left.queuedMessages !== undefined - ? queuePublicationOf(left) - : {}), sessionId: right.sessionId, batch: { cursor: right.batch.cursor, diff --git a/src/shared/structured-agent-session-command-refusal-cause.ts b/src/shared/structured-agent-session-command-refusal-cause.ts new file mode 100644 index 000000000000..4720435c620e --- /dev/null +++ b/src/shared/structured-agent-session-command-refusal-cause.ts @@ -0,0 +1,27 @@ +import { + isAgentSessionConversationCommand, + type AgentSessionConversationCommandResult +} from './agent-session-conversation-command' +import { readWholeAgentSessionFailureFact } from './agent-session-failure' +import type { StructuredAgentSessionCommandRefusalCause } from './structured-agent-session-composer' + +/** The host's refusal reasons whose words name something a chat shows. */ +const CAUSE_OF_HOST_REFUSAL: Partial> = { + turnActive: 'working', + messagesUnsettled: 'working', + promptPending: 'prompt', + backgroundTasksRunning: 'background' +} + +/** What a host's refusal of a command waits on, as a chat shows it; undefined for any other + * failure, or one this build can't read whole. */ +export function structuredAgentSessionCommandHostRefusalCause( + result: AgentSessionConversationCommandResult +): StructuredAgentSessionCommandRefusalCause | undefined { + const details = isAgentSessionConversationCommand(result.command) + ? readWholeAgentSessionFailureFact(result.failure)?.refusal?.details + : undefined + return details && 'reason' in details && typeof details.reason === 'string' + ? CAUSE_OF_HOST_REFUSAL[details.reason] + : undefined +} diff --git a/src/shared/structured-agent-session-command-turn-identity.ts b/src/shared/structured-agent-session-command-turn-identity.ts new file mode 100644 index 000000000000..cd13207e7fe4 --- /dev/null +++ b/src/shared/structured-agent-session-command-turn-identity.ts @@ -0,0 +1,28 @@ +import { agentJournalItemKey } from './agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from './agent-session-journal-types' + +/** The turn a conversation command such as `/compact` runs as, keyed by its submission. The queue + * reads it too: a refused command card is spent only once this turn has said why. */ +export function structuredAgentSessionCommandTurnIdentity( + clientMessageId: string +): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `command-turn:${clientMessageId}` } +} + +/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the + * host's Stop and delivery gate tell a command's turn from any other. */ +export function structuredAgentSessionCommandTurn(clientMessageId: string): { + identity: AgentJournalItemIdentity + itemId: string + turnId: string + /** The command's one result row, inside its turn. */ + resultIdentity: AgentJournalItemIdentity +} { + const identity = structuredAgentSessionCommandTurnIdentity(clientMessageId) + return { + identity, + itemId: agentJournalItemKey(identity), + turnId: `compact:${clientMessageId}`, + resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` } + } +} diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 0673d62dd26b..4bdea4f68bc0 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -36,16 +36,27 @@ export type StructuredAgentSessionComposerOptions = { conversationCommands?: readonly AgentSessionConversationCommand[] runConversationCommand?: ( command: AgentSessionConversationCommand - ) => Promise<{ accepted: boolean; error: string | null }> + ) => Promise> /** Present only where the host can set this session's goal; otherwise `/goal` * stays message text the agent acts on itself. */ setThreadGoalObjective?: (objective: string) => Promise } +/** What the chat shows a refused command waiting on: the agent working, a pending prompt, its + * background tasks, a message of this window's still being sent, or one showing its Retry. */ +export type StructuredAgentSessionCommandRefusalCause = + | 'working' + | 'prompt' + | 'background' + | 'sending' + | 'retry' + export type StructuredAgentSessionCommandOutcome = { handled: boolean accepted: boolean error: string | null + /** The refusal is said only while this still holds. */ + refusedWhile?: StructuredAgentSessionCommandRefusalCause } function commandParts(text: string): { name: string; argument: string } | null { @@ -105,6 +116,16 @@ export function isStructuredAgentSessionComposerCommand( ) } +/** `/clear` or `/compact` alone: a conversation command with nothing after it. */ +export function isLoneStructuredAgentSessionConversationCommand(text: string): boolean { + const command = commandParts(text.trim()) + return Boolean( + command && + command.argument === '' && + CONVERSATION_COMMANDS.some((entry) => entry.name === command.name) + ) +} + /** `/goal …`, which the host answers only where it can set this session's goal. */ export function isStructuredAgentSessionGoalCommand(text: string): boolean { return commandParts(text)?.name === 'goal'