From ab08cfd94b8d846e257721f58f92e3649d01a2cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:24:12 -0700 Subject: [PATCH 01/27] fix(native-chat): a /compact sent while the agent works waits in line instead of being refused --- .../MobileNativeChatQueuedMessages.test.tsx | 14 ++ .../MobileNativeChatQueuedMessages.tsx | 53 ++-- ...uctured-agent-session-host-support.test.ts | 15 ++ ...e-structured-agent-session-host-support.ts | 4 + ...mobile-structured-composer-command.test.ts | 24 ++ .../mobile-structured-composer-command.ts | 12 +- ...le-structured-queued-message-cards.test.ts | 22 ++ .../mobile-structured-queued-message-cards.ts | 5 +- ...tured-agent-session-prompt-cancel.test.tsx | 1 + ...tured-agent-session-queued.test-fixture.ts | 1 + .../use-mobile-structured-agent-session.ts | 3 + ...use-mobile-structured-send-with-outcome.ts | 9 + ...structured-agent-session-mutation-plans.ts | 19 +- ...tured-agent-session-queued-compact.test.ts | 232 ++++++++++++++++++ ...tructured-agent-session-queued-messages.ts | 4 +- ...ructured-agent-session-queued-mutations.ts | 42 ++-- .../structured-agent-session-rewind.test.ts | 43 ++++ ...red-conversation-command-admission.test.ts | 59 +++++ ...ructured-conversation-command-admission.ts | 17 +- .../structured-conversation-command.ts | 2 + .../structured-conversation-compaction.ts | 41 +++- .../NativeChatQueuedMessageCard.tsx | 39 +-- .../NativeChatQueuedMessageList.test.tsx | 19 ++ ...uctured-agent-session-queued-cards.test.ts | 18 ++ .../structured-agent-session-queued-cards.ts | 8 +- ...ructured-conversation-command-send.test.ts | 49 +++- .../structured-conversation-command-send.ts | 21 ++ ...tured-agent-session.queued-gating.test.tsx | 125 ++++++++++ .../use-structured-agent-session.ts | 32 ++- ...tructured-agent-session-host-capability.ts | 10 + .../agent-session-conversation-command.ts | 4 + src/shared/protocol-version.ts | 7 + .../structured-agent-session-params.ts | 9 +- 33 files changed, 876 insertions(+), 87 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a57..d7ae8a40f4bc 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -156,6 +156,20 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) + it('offers a command card no Steer or Send, only Delete and the menu', async () => { + const mounted = await mount({ + cards: [ + card({ messageId: 'compact', text: '/compact', command: true }), + card({ messageId: 'returned', text: '/compact', command: true, state: 'returned' }) + ], + onSend: vi.fn(async () => true) + }) + expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual([]) + expect( + mounted.root.findAllByProps({ accessibilityLabel: 'Delete this queued message' }) + ).toHaveLength(2) + }) + it('deletes a card from its trash button', async () => { const onDelete = vi.fn(async () => true) const mounted = await mount({ cards: [card({ messageId: 'w' })], onDelete }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 57f2fa410ffe..d0012626b34d 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -134,31 +134,34 @@ export function MobileNativeChatQueuedMessages({ ) : null} - [ - styles.textAction, - pressed && styles.pressed, - busy && styles.disabled - ]} - disabled={busy} - onPress={() => void run(card.messageId, onSend)} - > - {steers ? ( - - ) : ( - - )} - {steers ? 'Steer' : 'Send'} - + {/* A command runs when the agent finishes, so it offers no Steer or Send. */} + {card.command ? null : ( + [ + styles.textAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={() => void run(card.messageId, onSend)} + > + {steers ? ( + + ) : ( + + )} + {steers ? 'Steer' : 'Send'} + + )} { promptCancel: false, questionAnswers: false, queuedMessages: false, + queuedCommands: false, quietRepeatedStop: false }) expect( @@ -21,6 +23,7 @@ describe('structuredAgentSessionHostSupport', () => { promptCancel: false, questionAnswers: true, queuedMessages: false, + queuedCommands: false, quietRepeatedStop: false }) expect( @@ -29,6 +32,7 @@ describe('structuredAgentSessionHostSupport', () => { promptCancel: true, questionAnswers: false, queuedMessages: false, + queuedCommands: false, quietRepeatedStop: false }) expect( @@ -37,6 +41,16 @@ describe('structuredAgentSessionHostSupport', () => { promptCancel: false, questionAnswers: false, queuedMessages: true, + queuedCommands: false, + quietRepeatedStop: false + }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY]) + ).toEqual({ + promptCancel: false, + questionAnswers: false, + queuedMessages: false, + queuedCommands: true, quietRepeatedStop: false }) expect( @@ -45,6 +59,7 @@ describe('structuredAgentSessionHostSupport', () => { promptCancel: false, questionAnswers: false, queuedMessages: false, + queuedCommands: false, 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 9ae1d1e90b1a..798a9f26ccd0 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_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -11,6 +12,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 /** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */ quietRepeatedStop: boolean } @@ -22,6 +25,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), 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..74253b2bb91c 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -33,6 +33,7 @@ function setup() { conversationCommands: ['clear', 'compact'] }, canRun: () => true, + waitsInLine: () => false, onError: vi.fn(), timeoutMs: 15000 } @@ -119,6 +120,29 @@ 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.canRun = () => false + 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('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..4ffad643806b 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -1,4 +1,7 @@ -import type { AgentSessionConversationCommandResult } from '../../../src/shared/agent-session-conversation-command' +import type { + AgentSessionConversationCommand, + AgentSessionConversationCommandResult +} from '../../../src/shared/agent-session-conversation-command' import { dispatchStructuredAgentSessionComposerCommand, isStructuredAgentSessionComposerCommand, @@ -17,6 +20,8 @@ export async function dispatchMobileStructuredCommand(input: { pending: { current: boolean } controller: StructuredAgentSessionComposerOptions canRun: () => boolean + /** The host holds this command as a card behind work in flight, so nothing here holds it. */ + waitsInLine: (command: AgentSessionConversationCommand) => boolean onError: (message: string) => void timeoutMs: number }): Promise { @@ -34,7 +39,8 @@ export async function dispatchMobileStructuredCommand(input: { const outcome = await dispatchStructuredAgentSessionComposerCommand(input.text, { ...input.controller, runConversationCommand: async (command) => { - if (!input.canRun()) { + const waitsInLine = input.waitsInLine(command) + if (!waitsInLine && !input.canRun()) { return { accepted: false, error: 'Wait for pending work to finish before using this command.' @@ -49,7 +55,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 ( diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 44d1e0b9729f..f0ba4693bd04 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,28 @@ 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( + mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { pendingPrompt: false })[0] + ).not.toHaveProperty('command') + }) + 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 f3edb13e53c8..c79710527ece 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -23,6 +23,8 @@ export type MobileQueuedMessageCard = { needsAttention: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ caption: string | null + /** A conversation command such as /compact: it waits for the agent and never steers. */ + command?: true } function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { @@ -131,7 +133,8 @@ export function mobileQueuedMessageCards( needsAttention: draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), - caption + caption, + ...(draft.body.command !== undefined ? { command: true as const } : {}) }) if (draft.state === 'returned') { behindReturned = true 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 0bb7a8f74285..df854f43f25e 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 @@ -125,6 +125,7 @@ function Harness({ promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported, queuedMessages: false, + queuedCommands: false, quietRepeatedStop: false }, onSendError: vi.fn() 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 bbfe297ef7f3..cb8212bfcc08 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, quietRepeatedStop: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 7bec053c9d67..f4de9b0e2b8c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -99,6 +99,8 @@ export function useMobileStructuredAgentSession(args: { } = args // Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop. 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]) @@ -147,6 +149,7 @@ export function useMobileStructuredAgentSession(args: { sessionKey, enabled, queueCapable, + commandsWait, stateRef, commandPending: commandPendingRef, controller: sendController, 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 613dd14fe084..0f9d49567a06 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -35,6 +35,8 @@ export function useMobileStructuredSendWithOutcome(args: { sessionKey: string 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< @@ -53,6 +55,7 @@ export function useMobileStructuredSendWithOutcome(args: { callerIdentity, client, commandPending, + commandsWait, controller, enabled, onSendError, @@ -99,6 +102,11 @@ export function useMobileStructuredSendWithOutcome(args: { !stateRef.current.items.some( (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) ), + // 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 }) @@ -131,6 +139,7 @@ export function useMobileStructuredSendWithOutcome(args: { callerIdentity, client, commandPending, + commandsWait, controller, enabled, onSendError, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 6a2a6268ae94..88389171a7df 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 @@ -15,6 +15,7 @@ import type { AgentSessionMutationEnvelope, AgentSessionOptionResult, AgentSessionPromptResult, + AgentSessionQueuedSendReceipt, AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' @@ -142,13 +143,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 @@ -156,7 +161,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, { @@ -172,6 +180,13 @@ 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. + const queued = queuedSendAnswer(ctx.journal, clientMessageId) + 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-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts new file mode 100644 index 000000000000..c5c4db5669ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts @@ -0,0 +1,232 @@ +// 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 } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +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, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +function compact(delivery?: 'queue-if-active', clientOperationId = hostTestOperationId()) { + const fields = { command: 'compact' as const, ...(delivery ? { delivery } : {}) } + return { + id: clientOperationId, + result: rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', clientOperationId), + ...fields + }) + } +} + +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 settleMs = () => new Promise((resolve) => setTimeout(resolve, 150)) + +describe('a /compact that waits in line', () => { + 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: 'This command runs once the agent finishes.' } + }) + 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('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 never queues', () => { + 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: 'clear', delivery: 'queue-if-active' })).toBe(false) + expect(parse({ command: 'compact', delivery: 'now' })).toBe(false) +}) 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 248534097b8d..03c745010d4b 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 @@ -83,8 +83,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 ee32b3648a90..3dc90d7568fa 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 @@ -84,7 +84,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 @@ -104,21 +105,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: queuedMessageFingerprint(input.replacementSessionId, row.body), - hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId - }) + // A command card was for the context the clear discards: withdrawn below, never 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: queuedMessageFingerprint(input.replacementSessionId, row.body), + hostInstance: structuredAgentSessionHostInstance(), + carriedFrom: ctx.sessionId + }) + } } await withdrawQueuedMessagesForOperation(ctx.journal, { sessionId: ctx.sessionId, @@ -209,6 +214,11 @@ export function sendQueuedStructuredAgentMessage( if (row.state === 'withdrawn') { return invalid('This queued message was withdrawn.') } + // A command never steers: handed over mid-turn it would only be refused. Clients offer no + // Send-now on a command card; this answers one that does. + if (hold === 'working' && row.body.command && row.state !== 'dispatched') { + return invalid('This command runs once the agent finishes.') + } if (row.state === 'dispatched') { // Already a submission — answer with it rather than sending twice. const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) 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 5a359deebad3..439f017976ad 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 @@ -299,6 +299,49 @@ 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() + expect( + await host.send(caller, { + body, + envelope: { + ...(await params(target)).envelope, + clientOperationId, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) + // The adapter's reply is lost: a live doubt, which the chat shows as working. + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.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: { + provider: 'codex', + threadId: HOST_TEST_THREAD, + turnId: 'late', + ordinal: 0 + } + }) + 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..6d35f9368a3d 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,60 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => expect(conversationCommandBlocked(ctx, RECORD, [], 'handover')).toBeNull() }) }) + +describe('conversationCommandBlocked on unsettled messages', () => { + // One send per shape the Working indicator and admission could read differently. + const SHAPES = { + queued: { dispatchState: 'pending', handoverRecorded: true, fence: 1 }, + handedOver: { dispatchState: 'pending', handoverRecorded: true, handedOverAt: 5, fence: 1 }, + handedOverByEarlierChild: { + dispatchState: 'pending', + handoverRecorded: true, + handedOverAt: 5, + fence: 0 + }, + liveUnknown: { dispatchState: 'unknown', fence: 1 }, + recoveredUnknown: { dispatchState: 'unknown', fence: 1, recovered: true }, + restartedUnknown: { + dispatchState: 'unknown', + fence: 1, + reason: 'host_restarted_before_acknowledgement' + }, + accepted: { dispatchState: 'accepted', fence: 1 }, + rejected: { dispatchState: 'rejected', fence: 1 } + } + + function admissionOver(shape: keyof typeof SHAPES, admission?: 'at-rest' | 'handover') { + const ctx = contextWith(undefined) + const submission = { clientMessageId: shape, ...SHAPES[shape] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the dispatch fields. + const submissions = [submission] as unknown as AgentJournalSubmission[] + 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(SHAPES) as (keyof typeof SHAPES)[])( + 'refuses on %s exactly when the chat shows the agent working', + (shape) => { + const { blocked, working } = admissionOver(shape) + expect(blocked).toBe(working ? 'messagesUnsettled' : null) + expect(admissionOver(shape, '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 73093da47447..9df1506fe642 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 @@ -38,6 +38,8 @@ 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' } export type ConversationReplacement = { sourceSessionId: string 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..81b53494e53d 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 { @@ -34,6 +35,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 +71,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 later refusal at hand-off returns it. + 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 +133,14 @@ function compactionReply( function acceptStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { envelope: AgentSessionMutationEnvelope }, + params: { envelope: AgentSessionMutationEnvelope; delivery?: 'queue-if-active' }, 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 +148,27 @@ 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.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/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 29432e643304..aee6ded668a9 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -150,22 +150,29 @@ export function NativeChatQueuedMessageCard({

) : null} - - - - - - - {sendNow.hint} - {showsSteerShortcut ? ( - - ) : null} - - - + {/* A command runs when the agent finishes, so it offers no Send-now. */} + {card.command ? null : ( + + + + + + + {sendNow.hint} + {showsSteerShortcut ? ( + + ) : null} + + + + )} - - - {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} - + {/* 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')} + + )} {translate( 'components.native-chat.queuedMessages.turnOffQueueing', diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 0ef8f719403b..bbf785ec5ade 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -135,6 +135,13 @@ describe('NativeChatQueuedMessageList', () => { expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2) }) + 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' })], 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 98155e51bbdd..c41fec8ba410 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,10 @@ const SENTENCES: Record string> = { 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), optionRejected: () => translate('components.native-chat.writeNotice.optionRejected', COPY.optionRejected), goalsUnsupported: () => 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 7b0c215dd286..24207e4d9edc 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 @@ -297,4 +297,15 @@ describe('a command held here', () => { 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." + }) + }) }) 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 08470fb2bebf..a60835be2064 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 @@ -43,15 +43,21 @@ export function structuredConversationCommandHold(input: { return input.turnActive || input.outboxHeld ? 'working' : null } -/** The line a command refused here gets: a /clear says what the person sees and can do. */ +/** 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 { - if (command === 'clear' && (hold === 'working' || hold === 'prompt')) { - return agentSessionWriteNoticeText( - hold === 'prompt' ? ['clearAfterAnswer'] : ['agentStillWorking', 'runClearWhenDone'] - ) + if (hold === 'prompt') { + return agentSessionWriteNoticeText([ + command === 'clear' ? 'clearAfterAnswer' : 'compactAfterAnswer' + ]) + } + if (hold === 'working') { + return agentSessionWriteNoticeText([ + 'agentStillWorking', + command === 'clear' ? 'runClearWhenDone' : 'runCompactWhenDone' + ]) } return translate( 'components.native-chat.conversationCommand.pendingWork', diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 4596fb67180f..4172ccc18345 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 @@ -124,7 +124,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) 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 d5d64d728c73..0686ec4bf838 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 @@ -496,7 +496,7 @@ describe('a /compact against a host that holds commands but has its queue dark', }) expect(outcome).toEqual({ accepted: false, - error: 'Wait for pending work and messages to finish before using this command.' + error: "The agent is still working. Run /compact when it's done." }) expect(commandCalls()).toHaveLength(0) items = [] diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 32bca33ab7ce..821cef88dcca 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2869,6 +2869,7 @@ "clearAfterAnswer": "Answer the agent's question or approval, then run /clear.", "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.", "conversationCleared": "This conversation has been cleared.", "goalsUnsupported": "This agent doesn't support goals.", "historyUnavailable": "Orca couldn't open this chat's history right now.", @@ -2899,6 +2900,7 @@ "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 87517f63f206..008788d579b1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17823,6 +17823,8 @@ "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.", "optionRejected": "The agent didn't accept this setting.", "goalsUnsupported": "This agent doesn't support goals.", "agentRefused": "The agent turned this down.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c87605498707..5a94a1013ad5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17585,6 +17585,8 @@ "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.", "optionRejected": "El agente no aceptó este ajuste.", "goalsUnsupported": "Este agente no admite objetivos.", "agentRefused": "El agente lo rechazó.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 8c870bfd9a70..4fb8e57ed4f3 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17684,6 +17684,8 @@ "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.", "optionRejected": "L'agent n'a pas accepté ce réglage.", "goalsUnsupported": "Cet agent ne prend pas en charge les objectifs.", "agentRefused": "L'agent l'a refusé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d876007e54b9..b76c9b6ddea6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17620,6 +17620,8 @@ "agentStillWorking": "Agent はまだ作業中です。", "runClearWhenDone": "終わったら /clear を実行してください。", "clearAfterAnswer": "Agent の質問または承認に回答してから、/clear を実行してください。", + "runCompactWhenDone": "終わったら /compact を実行してください。", + "compactAfterAnswer": "Agent の質問または承認に回答してから、/compact を実行してください。", "optionRejected": "Agent はこの設定を受け入れませんでした。", "goalsUnsupported": "この Agent は目標に対応していません。", "agentRefused": "Agent がこれを拒否しました。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1c07b6a9e346..8f6ea662e02c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17620,6 +17620,8 @@ "agentStillWorking": "에이전트가 아직 작업 중입니다.", "runClearWhenDone": "작업이 끝나면 /clear를 실행하세요.", "clearAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /clear를 실행하세요.", + "runCompactWhenDone": "작업이 끝나면 /compact를 실행하세요.", + "compactAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /compact를 실행하세요.", "optionRejected": "에이전트가 이 설정을 받아들이지 않았습니다.", "goalsUnsupported": "이 에이전트는 목표를 지원하지 않습니다.", "agentRefused": "에이전트가 이를 거부했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ab774e4e1014..e0d96165a8fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17585,6 +17585,8 @@ "agentStillWorking": "智能体仍在工作。", "runClearWhenDone": "完成后再运行 /clear。", "clearAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /clear。", + "runCompactWhenDone": "完成后再运行 /compact。", + "compactAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /compact。", "optionRejected": "智能体未接受此设置。", "goalsUnsupported": "此智能体不支持目标。", "agentRefused": "智能体拒绝了此请求。", diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 7e5a6ec4175f..d16f97c98977 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -44,6 +44,7 @@ const WRITES: AgentSessionWriteKind[] = [ 'option', 'command', 'clear', + 'compact', 'goal' ] const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.' @@ -93,6 +94,7 @@ const NOT_DONE: Record = option: 'notDoneOption', command: 'notDoneCommand', clear: 'notDoneCommand', + compact: 'notDoneCommand', goal: 'notDoneGoal' } @@ -446,10 +448,15 @@ describe('the notice for every reason a host names', () => { failure.code === 'structured_agent_session_unsupported' && write !== 'read-history' const saysNotDone = write === 'read-history' && failure.code === 'agent_session_journal_unreadable' - // "Run /clear when it's done" already says the clear has yet to happen. - const clearWhileWorking = - write === 'clear' && - (parts.includes('runClearWhenDone') || parts.includes('clearAfterAnswer')) + // "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 || clearWhileWorking ? [] : [NOT_DONE[write]] ) @@ -614,7 +621,7 @@ describe('a chat whose history the host could not open', () => { }) }) -describe('a /clear refused while the agent works', () => { +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 @@ -631,7 +638,20 @@ describe('a /clear refused while the agent works', () => { ).toBe("Answer the agent's question or approval, then run /clear.") }) - it('leaves every other command its own words', () => { + 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', @@ -639,12 +659,15 @@ describe('a /clear refused while the agent works', () => { ]) }) - it('is the write a /clear is, and only a /clear', () => { + 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') }) }) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 6cef56bd6f55..c997eede6e29 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -47,15 +47,25 @@ const NOT_DONE: Record = option: 'notDoneOption', command: 'notDoneCommand', clear: 'notDoneCommand', + compact: 'notDoneCommand', goal: 'notDoneGoal' } -/** A /clear refused because the agent is working: one plain sentence for every reason that is, - * saying only what the person sees and can do. */ -const CLEAR_WHILE_WORKING: Partial> = { - turnActive: ['agentStillWorking', 'runClearWhenDone'], - messagesUnsettled: ['agentStillWorking', 'runClearWhenDone'], - promptPending: ['clearAfterAnswer'] +/** 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 @@ -88,12 +98,12 @@ function reasonParts( write: AgentSessionWriteKind, context: AgentSessionFailureWordsContext ): AgentSessionWriteNoticePart[] | undefined { - const clearWhileWorking = - write === 'clear' && failure.code === 'agent_session_operation_invalid' - ? CLEAR_WHILE_WORKING[failure.details?.reason ?? ''] + const commandWhileWorking = + failure.code === 'agent_session_operation_invalid' + ? COMMAND_WHILE_WORKING[write]?.[failure.details?.reason ?? ''] : undefined - if (clearWhileWorking) { - return clearWhileWorking + if (commandWhileWorking) { + return commandWhileWorking } const words = agentSessionRefusalReasonWords(failure) if (!words || 'words' in words) { diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index 8046ac4f6075..31764bc33a8a 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -29,8 +29,9 @@ export type AgentSessionWriteKind = | 'answer' | 'option' | 'command' - /** A /clear: refused while the agent works, in words of its own. */ + /** 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,8 +58,11 @@ export function agentSessionWriteKindForMethod( if (fingerprintMethod === 'agentSession.threadGoal') { return 'goal' } - if (fingerprintMethod === 'agentSession.conversationCommand' && fields.command === 'clear') { - return 'clear' + 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 23159754f856..bbefc245aa2c 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -61,6 +61,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { 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.", optionRejected: "The agent didn't accept this setting.", goalsUnsupported: "This agent doesn't support goals.", agentRefused: 'The agent turned this down.', From 00692103a67dba17433549fe0b4ac54cef64737e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:20:38 -0700 Subject: [PATCH 06/27] test(native-chat): a refused /compact reads the plain sentence --- .../use-structured-agent-session-write-refusal.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 034c2a361b3b..14eda86e2877 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 @@ -254,8 +254,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." }) }) From b67b317c6ce011193a3a771f55619d9ea718e736 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:55:42 -0700 Subject: [PATCH 07/27] fix(native-chat): a refused command card is spent and said once; a command card offers Send while the agent is idle; /compact behind an unsent message is not taken --- .../MobileNativeChatQueuedMessages.test.tsx | 15 ++- .../MobileNativeChatQueuedMessages.tsx | 12 +- ...le-structured-queued-message-cards.test.ts | 13 ++ .../mobile-structured-queued-message-cards.ts | 13 +- .../use-mobile-structured-agent-session.ts | 1 + ...bile-structured-queued-message-controls.ts | 6 +- .../queued-message-table.ts | 17 ++- .../structured-agent-session-command-turn.ts | 68 ++++++---- ...tured-agent-session-queued-compact.test.ts | 92 +++++++++++++- ...ructured-agent-session-queued-mutations.ts | 10 +- .../NativeChatQueuedMessageCard.tsx | 6 +- .../NativeChatQueuedMessageList.test.tsx | 28 ++--- .../agent-session-write-notice-text.ts | 4 + ...uctured-agent-session-queued-cards.test.ts | 7 ++ .../structured-agent-session-queued-cards.ts | 13 +- ...ructured-conversation-command-send.test.ts | 68 +++++----- .../structured-conversation-command-send.ts | 109 ++++++++++++---- ...tructured-agent-session-queued-messages.ts | 6 +- ...tured-agent-session.queued-gating.test.tsx | 119 ++++++++++++------ .../use-structured-agent-session.ts | 7 +- ...se-structured-conversation-command-hold.ts | 54 -------- ...use-structured-conversation-command-run.ts | 66 ---------- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-write-notice-copy.ts | 2 + 30 files changed, 460 insertions(+), 290 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/use-structured-conversation-command-hold.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-conversation-command-run.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 0bcb37dd4062..ce96dcd3f2f3 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -156,15 +156,20 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) - it('offers a command card no Steer or Send, only Delete and the menu', async () => { + 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: 'compact', text: '/compact', command: true }), - card({ messageId: 'returned', text: '/compact', command: true, state: 'returned' }) + card({ messageId: 'working', text: '/compact', command: true, waitsForAgent: true }), + card({ messageId: 'idle', text: '/compact', command: true }) ], - onSend: vi.fn(async () => 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(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual([]) + 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( diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 259ba2cbf28d..8cd5d2f59f28 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -107,7 +107,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} - {/* A command runs when the agent finishes, so it offers no Steer or Send. */} - {card.command ? null : ( + {/* A command never steers: its Send shows only while the agent is idle. */} + {card.waitsForAgent ? null : ( [ styles.textAction, 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 f0ba4693bd04..28dce918ec0e 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -47,9 +47,22 @@ describe('mobileQueuedMessageCards', () => { { 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('renders nothing without a published list', () => { diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index c79710527ece..c3e3dea73926 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -23,8 +23,10 @@ export type MobileQueuedMessageCard = { needsAttention: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ caption: string | null - /** A conversation command such as /compact: it waits for the agent and never steers. */ + /** 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 { @@ -93,7 +95,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 [] @@ -134,7 +136,12 @@ export function mobileQueuedMessageCards( draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), caption, - ...(draft.body.command !== undefined ? { command: true as const } : {}) + ...(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-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index f4de9b0e2b8c..e84a0822192c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -205,6 +205,7 @@ export function useMobileStructuredAgentSession(args: { queuePause, submissions: state.submissions, pendingPrompt: approvalPrompt !== null || questionPrompt !== null, + agentWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), mutate, appendComposerText, onSendError, 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 3b1044319c6f..77586b310bca 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -49,6 +49,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,15 +71,17 @@ export function useMobileStructuredQueuedMessageControls(args: { sessionKey, submissions } = args + const agentWorking = args.agentWorking === true const cards = useMemo( () => queueCapable ? mobileQueuedMessageCards(queuedMessages, submissions, { pendingPrompt, + agentWorking, queuePaused: queuePause !== null }) : [], - [pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] + [agentWorking, pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] ) const resolved = useCallback( (accepted: boolean): boolean => { diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index a2bd9c3e05ab..43006d952d68 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 @@ -217,7 +217,8 @@ export function withdrawQueuedMessages( } /** - * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), + * dispatched → returned, or back to waiting (`rejectedDraftSettlement`); a refused command + * card is spent (withdrawn) instead of returned, * 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; @@ -234,6 +235,20 @@ export function settleRejectedQueuedMessage( } ): boolean { const settlement = rejectedDraftSettlement({ reason: input.reason, rejection: input.rejection }) + if (settlement.state === 'returned') { + // A refused command is spent, not returned: its turn's row says why, once, and a returned + // card would hold every card behind it. + 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) + if (Number(spent.changes ?? 0) > 0) { + return true + } + } const changed = settlement.state === 'waiting' ? db 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 b1654ff70790..c2faae1fb2f3 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 @@ -172,34 +172,21 @@ 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, 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 openCommandTurn(ctx, submission) + await settleUnsentCommand(ctx, clientMessageId, refused) 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!({ @@ -237,6 +224,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-queued-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts index bd6e7a7d20f6..6f7cdf21e3bf 100644 --- 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 @@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, it } 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, @@ -69,6 +72,19 @@ function prompt(state: 'pending' | 'resolved') { ) } +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', () => { @@ -139,7 +155,7 @@ describe('a /compact that waits in line', () => { const compactId = await queuedCompact() expect(await rig.sendNow(compactId)).toMatchObject({ ok: false, - refusal: { message: 'This command runs once the agent finishes.' } + refusal: { message: "A command can't be sent while the agent is working." } }) expect(await rig.handoff(compactId)).toBeUndefined() await rig.settleAccepted(working, 'a') @@ -209,6 +225,80 @@ describe('a /compact that waits in line', () => { 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') + await eventually(async () => + expect(await rig.handoff(later.value.queued.messageId)).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') + await eventually(async () => + expect(await rig.handoff(later.value.queued.messageId)).toBeDefined() + ) + expect(rig.compact).not.toHaveBeenCalled() + expect(await rig.handoff(compactId)).toMatchObject({ dispatchState: 'rejected' }) + const { failures, cards } = await failureRowsAndCards() + expect(failures).toHaveLength(1) + expect(cards).toEqual([]) + }) + + 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()) + }) + it('a /clear drops a waiting command card instead of carrying it to the new chat', async () => { const working = await rig.workingSend() await queuedCompact() 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 3dc90d7568fa..5cefd7d0cf7d 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 @@ -214,11 +214,6 @@ export function sendQueuedStructuredAgentMessage( if (row.state === 'withdrawn') { return invalid('This queued message was withdrawn.') } - // A command never steers: handed over mid-turn it would only be refused. Clients offer no - // Send-now on a command card; this answers one that does. - if (hold === 'working' && row.body.command && row.state !== 'dispatched') { - return invalid('This command runs once the agent finishes.') - } if (row.state === 'dispatched') { // Already a submission — answer with it rather than sending twice. const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) @@ -226,6 +221,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/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index a42eee9f04a7..4c7f8a367d26 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -86,7 +86,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { label: string hint: string } { - if (card.hold === 'paused' || card.hold === 'returned') { + if (card.hold === 'paused' || card.hold === 'returned' || card.command) { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), @@ -150,8 +150,8 @@ export function NativeChatQueuedMessageCard({

) : null} - {/* A command runs when the agent finishes, so it offers no Send-now. */} - {card.command ? 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')} + )} - {sendNow.label} - -
- - - {sendNow.hint} - {showsSteerShortcut ? ( - - ) : null} - - -
+ + {translate( + 'components.native-chat.queuedMessages.turnOffQueueing', + 'Turn off queueing' + )} + + + + )} - - - - - - {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')} - - )} - - {translate( - 'components.native-chat.queuedMessages.turnOffQueueing', - 'Turn off queueing' - )} - - - ) } diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index a5c84d85e979..9123e20b09c2 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -129,6 +129,27 @@ describe('NativeChatQueuedMessageList', () => { 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() 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 47ce0552c596..52a247f6dfb6 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 @@ -36,7 +36,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 59c4947c8b4f..78f456c1fc30 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 @@ -183,6 +183,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 16d6fc7bc33b..43d2af5f26d6 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 @@ -118,6 +118,10 @@ const SENTENCES: Record string> = { 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), optionRejected: () => translate('components.native-chat.writeNotice.optionRejected', COPY.optionRejected), goalsUnsupported: () => 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 9bd01bbfccb7..71f4821f488e 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 @@ -25,6 +25,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 @@ -106,7 +109,21 @@ export function newestSteerableQueuedMessageCard( cards: readonly QueuedMessageCard[] ): QueuedMessageCard | null { const newest = cards.at(-1) - return newest && !newest.command ? newest : null + 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 StructuredAgentSessionOutboxEntry[] +): 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' + })) } /** @@ -124,17 +141,31 @@ export function outboxOutsideQueuedCards( isWorking: boolean, host: StructuredAgentSessionQueueDelivery ): readonly StructuredAgentSessionOutboxEntry[] { + const held = new Set(heldIds) + const onItsWay = new Set(outboxQueueSendsOnTheirWay(outbox, heldIds, isWorking, host)) + const next = outbox.filter((entry) => !held.has(entry.clientMessageId) && !onItsWay.has(entry)) + return next.length === outbox.length ? outbox : next +} + +/** The queue sends on their way that the host holds no card for yet: shown as sending cards. */ +export function outboxQueueSendsOnTheirWay( + outbox: readonly StructuredAgentSessionOutboxEntry[], + heldIds: readonly string[], + isWorking: boolean, + host: StructuredAgentSessionQueueDelivery +): StructuredAgentSessionOutboxEntry[] { + if (!isWorking) { + return [] + } const held = new Set(heldIds) const admission = admitStructuredAgentSessionOutboxEntry(outbox) const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 - const next = outbox.filter((entry, index) => { - const onItsWay = - isWorking && + return outbox.filter( + (entry, index) => + !held.has(entry.clientMessageId) && (stalledFrom === -1 || index < stalledFrom) && (entry.state === 'queued' || entry.state === 'dispatching') && !structuredAgentSessionEntryHeldForRetry(entry) && structuredAgentSessionEntryAsksToQueue(entry, host) - return !held.has(entry.clientMessageId) && !onItsWay - }) - return next.length === outbox.length ? outbox : next + ) } diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts index 46bf6b1b2060..8a41b8b83306 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 @@ -196,7 +196,8 @@ describe('a /compact the host holds in line', () => { agentWorking: false, promptPending: false, backgroundTasksRunning: false, - outboxHeld: false, + outboxRetry: false, + outboxSending: false, outboxUnsent: false } @@ -207,12 +208,10 @@ describe('a /compact the host holds in line', () => { ...inLine, agentWorking: true, promptPending: true, - outboxHeld: true + outboxRetry: true }) ).toBeNull() - expect( - structuredConversationCommandHold({ ...inLine, outboxHeld: true, outboxUnsent: true }) - ).toBe('ahead') + expect(structuredConversationCommandHold({ ...inLine, outboxUnsent: true })).toBe('ahead') expect(structuredConversationCommandHold({ ...inLine, backgroundTasksRunning: true })).toBe( 'background' ) @@ -221,12 +220,12 @@ describe('a /compact the host holds in line', () => { 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') - expect( - structuredConversationCommandHold({ ...idle, outboxHeld: true, outboxUnsent: true }) - ).toBe('working') + expect(structuredConversationCommandHold({ ...idle, outboxUnsent: true })).toBe('working') expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt') // Only a failed message waits for its Retry: the agent is not working. - expect(structuredConversationCommandHold({ ...idle, outboxHeld: true })).toBe('retry') + expect(structuredConversationCommandHold({ ...idle, outboxRetry: true })).toBe('retry') + // One a Stop kept on its way shows no Retry: it reads as still sending. + expect(structuredConversationCommandHold({ ...idle, outboxSending: true })).toBe('sending') }) }) @@ -268,6 +267,13 @@ describe('a command held here', () => { 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('behind only a failed message, names the step that clears the way', async () => { expect(await held('clear', 'retry').result).toEqual({ accepted: false, 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 5f6ccf79258b..8815b4f19b94 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 @@ -11,9 +11,16 @@ import { agentSessionFailureSentence } from '../../../../shared/agent-session-fa import { translate } from '@/i18n/i18n' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' +import { + agentSessionFailureStatedByStartRow, + structuredAgentSessionDeliveryNotices +} from './structured-agent-session-delivery-notices' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + structuredAgentSessionEntryRejectedByHost, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' @@ -26,6 +33,7 @@ export type StructuredConversationCommandHold = | 'prompt' | 'background' | 'retry' + | '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. */ @@ -36,8 +44,10 @@ export function structuredConversationCommandHold(input: { agentWorking: boolean promptPending: boolean backgroundTasksRunning: boolean - /** Any message this window sent that the host has not answered. */ - outboxHeld: boolean + /** A message this window sent shows its Retry on its row. */ + outboxRetry: boolean + /** A message this window sent is still on its way with no Retry on its row (one a Stop kept). */ + outboxSending: boolean /** A message this window sent that the host doesn't have yet. */ outboxUnsent: boolean }): StructuredConversationCommandHold | null { @@ -53,8 +63,11 @@ export function structuredConversationCommandHold(input: { if (input.agentWorking || input.outboxUnsent) { return 'working' } - // Only messages that failed are left, each with its own Retry: the agent is not working. - return input.outboxHeld ? 'retry' : null + // The agent is not working: what is left is said as its row says it. + if (input.outboxRetry) { + return 'retry' + } + return input.outboxSending ? 'sending' : null } /** The line a command refused here gets: what the person sees and can do, as the host says it. */ @@ -73,6 +86,8 @@ function heldCommandText( ]) case 'retry': return agentSessionWriteNoticeText([clear ? 'clearAfterRetry' : 'compactAfterRetry']) + case 'sending': + return agentSessionWriteNoticeText([clear ? 'clearAfterSending' : 'compactAfterSending']) case 'background': break } @@ -168,7 +183,7 @@ export function structuredConversationCommandRunner(args: { agentWorking: args.agentWorking, promptPending: args.promptPending, backgroundTasksRunning: args.backgroundTasksRunning, - outboxHeld: args.outbox.length > 0, + ...outboxRows(args.outbox, args.submissions, args.agentName), outboxUnsent: hasUnsentStructuredAgentSessionOutboxEntry(args.outbox, args.submissions) }), startFailures: args.startFailures, @@ -182,6 +197,34 @@ export function structuredConversationCommandRunner(args: { } } +/** What this window's own messages offer on their rows: a Retry, or nothing while still on + * their way. One the host already refused is the host's row, and holds nothing up. */ +function outboxRows( + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[], + agentName: string +): { outboxRetry: boolean; outboxSending: boolean } { + if (outbox.length === 0) { + return { outboxRetry: false, outboxSending: false } + } + const notices = structuredAgentSessionDeliveryNotices( + outbox, + agentName, + () => {}, + submissions, + [], + new Set() + ) + const offersRetry = (entry: StructuredAgentSessionOutboxEntry) => + notices.get(agentJournalSubmissionKey(entry.clientMessageId))?.onRetry !== undefined + return { + outboxRetry: outbox.some(offersRetry), + outboxSending: outbox.some( + (entry) => !offersRetry(entry) && !structuredAgentSessionEntryRejectedByHost(entry) + ) + } +} + /** 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-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index a307c6cf59d3..6b034d4e6806 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 @@ -16,10 +16,12 @@ import type { AgentSessionSendResult } from '../../../../shared/agent-session-wire' import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + sendingQueuedMessageCards, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -42,6 +44,8 @@ export type StructuredAgentSessionQueuedMessagesController = { steerNewest: () => boolean } +const NO_SENDS: readonly StructuredAgentSessionOutboxEntry[] = [] + function alreadySentNotice(): void { toast.error( translate('components.native-chat.queuedMessages.alreadySent', 'This message was already sent.') @@ -57,21 +61,26 @@ export function useStructuredAgentSessionQueuedMessages(args: { hasPendingPrompt: boolean /** The chat shows the agent working: a command card offers no send then. */ agentWorking?: boolean + /** Queue sends on their way that the host holds no card for yet. */ + sending?: readonly StructuredAgentSessionOutboxEntry[] composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args const agentWorking = args.agentWorking === true const pause = args.queuePause + const sending = args.sending ?? NO_SENDS const cards = useMemo( - () => - projectQueuedMessageCards(queuedMessages, submissions, { + () => [ + ...projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, agentWorking, queuePaused: pause !== null }), - [agentWorking, hasPendingPrompt, pause, queuedMessages, submissions] + ...sendingQueuedMessageCards(sending) + ], + [agentWorking, hasPendingPrompt, pause, queuedMessages, sending, submissions] ) const cardsRef = useRef(cards) useEffect(() => { 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 7aaac845c09d..310fe4535dd0 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 @@ -455,6 +455,55 @@ describe('a /compact against a host that holds commands in line', () => { }) }) + 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' }) + outboxEntries = [ + unsent('on-its-way', { + state: 'dispatching', + lastAttemptAt: 2, + sentDelivery: '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('/clear right after a Stop kept a send on its way names no Retry it does not show', async () => { + items = [] + outboxEntries = [ + unsent('kept', { + state: 'dispatching', + lastAttemptAt: 2, + sentDelivery: 'queue-if-active', + outlivedStop: true + }) + ] + 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.' + }) + }) + it('/clear behind only a failed message names its Retry, not the agent working', async () => { items = [] outboxEntries = [unsent('failed', { lastAttemptAt: 2, lastFailure: { kind: 'failed' } })] 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 56de7e455ef2..5f3955930f5d 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 @@ -33,7 +33,10 @@ import { useStructuredAgentSessionContextUsage } from './use-structured-agent-se import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' import { structuredConversationCommandRunner } from './structured-conversation-command-send' -import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' +import { + outboxOutsideQueuedCards, + outboxQueueSendsOnTheirWay +} from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' @@ -189,6 +192,11 @@ export function useStructuredAgentSession(args: { hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking + // Hidden from the transcript, a queue send on its way reads as sending among the cards. + const queueSendsOnTheirWay = useMemo( + () => outboxQueueSendsOnTheirWay(outbox, queuedMessageIds, isWorking, queueDelivery), + [isWorking, outbox, queueDelivery, queuedMessageIds] + ) const transcriptOutbox = useMemo( () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery), [isWorking, outbox, queueDelivery, queuedMessageIds] @@ -214,6 +222,7 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, agentWorking: transportState.turnId !== null || transportState.isWorking, + sending: queueSendsOnTheirWay, composerScopeKey, mutate }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 0d260f32cb25..ab4744f50961 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2774,6 +2774,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}}.", "commandRefused": "This command didn't run.", "commandRefusedTryAgain": "This command didn't run. Try it again.", "compactionFailed": "Compaction failed.", @@ -2804,6 +2805,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.", "signInFirst": "Sign in first.", @@ -2876,10 +2878,12 @@ "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.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bd871b6aa91f..ae7616cf8771 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17774,6 +17774,8 @@ "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.", + "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.", @@ -17840,6 +17842,8 @@ "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.", "optionRejected": "The agent didn't accept this setting.", "goalsUnsupported": "This agent doesn't support goals.", "agentRefused": "The agent turned this down.", @@ -18240,6 +18244,7 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", + "pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", "queuePausedRestarted": "Queue paused because Orca restarted", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 491fdd3f5780..9a0b9b512009 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17561,6 +17561,8 @@ "notDeliveredSendAgain": "Este mensaje no se entregó. Vuelva a enviarlo para continuar.", "commandRefused": "Este comando no se ejecutó.", "commandRefusedTryAgain": "Este comando no se ejecutó. Vuelva a intentarlo.", + "runCommandWhenDone": "Ejecute /{{command}} cuando termine.", + "commandAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /{{command}}.", "compactionFailed": "La compactación falló.", "compactionFailedQuoted": "La compactación falló: {{detail}}.", "compactionUnconfirmed": "No se confirmó que la compactación haya terminado.", @@ -17627,6 +17629,8 @@ "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.", "optionRejected": "El agente no aceptó este ajuste.", "goalsUnsupported": "Este agente no admite objetivos.", "agentRefused": "El agente lo rechazó.", @@ -17903,6 +17907,7 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", + "pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar.", "paused": "En pausa", "queuePausedStopped": "Cola en pausa porque interrumpiste", "queuePausedRestarted": "Cola en pausa porque Orca se reinició", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e745cf25abc0..7f76b9e02847 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17660,6 +17660,8 @@ "notDeliveredSendAgain": "Ce message n'a pas été remis. Renvoyez-le pour continuer.", "commandRefused": "Cette commande n'a pas été exécutée.", "commandRefusedTryAgain": "Cette commande n'a pas été exécutée. Réessayez.", + "runCommandWhenDone": "Lancez /{{command}} quand il aura terminé.", + "commandAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /{{command}}.", "compactionFailed": "La compaction a échoué.", "compactionFailedQuoted": "La compaction a échoué : {{detail}}.", "compactionUnconfirmed": "La fin de la compaction n'est pas confirmée.", @@ -17726,6 +17728,8 @@ "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.", "optionRejected": "L'agent n'a pas accepté ce réglage.", "goalsUnsupported": "Cet agent ne prend pas en charge les objectifs.", "agentRefused": "L'agent l'a refusé.", @@ -18120,6 +18124,7 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", + "pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer.", "paused": "En pause", "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a2fda03a1a3d..059da245d31c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17596,6 +17596,8 @@ "notDeliveredSendAgain": "このメッセージは配信されませんでした。続けるには、もう一度送信してください。", "commandRefused": "このコマンドは実行されませんでした。", "commandRefusedTryAgain": "このコマンドは実行されませんでした。もう一度お試しください。", + "runCommandWhenDone": "終わったら /{{command}} を実行してください。", + "commandAfterAnswer": "Agent の質問または承認に回答してから、/{{command}} を実行してください。", "compactionFailed": "圧縮に失敗しました。", "compactionFailedQuoted": "圧縮に失敗しました: {{detail}}。", "compactionUnconfirmed": "圧縮の完了は確認されていません。", @@ -17662,6 +17664,8 @@ "compactAfterAnswer": "Agent の質問または承認に回答してから、/compact を実行してください。", "clearAfterRetry": "以前のメッセージを再試行してから、/clear を実行してください。", "compactAfterRetry": "以前のメッセージを再試行してから、/compact を実行してください。", + "clearAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /clear を実行してください。", + "compactAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /compact を実行してください。", "optionRejected": "Agent はこの設定を受け入れませんでした。", "goalsUnsupported": "この Agent は目標に対応していません。", "agentRefused": "Agent がこれを拒否しました。", @@ -18056,6 +18060,7 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", + "pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 94272dd09248..7e7285a68e02 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17596,6 +17596,8 @@ "notDeliveredSendAgain": "이 메시지는 전달되지 않았습니다. 계속하려면 다시 보내세요.", "commandRefused": "이 명령이 실행되지 않았습니다.", "commandRefusedTryAgain": "이 명령이 실행되지 않았습니다. 다시 시도하세요.", + "runCommandWhenDone": "작업이 끝나면 /{{command}}을(를) 실행하세요.", + "commandAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /{{command}}을(를) 실행하세요.", "compactionFailed": "압축에 실패했습니다.", "compactionFailedQuoted": "압축에 실패했습니다: {{detail}}.", "compactionUnconfirmed": "압축 완료가 확인되지 않았습니다.", @@ -17662,6 +17664,8 @@ "compactAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /compact를 실행하세요.", "clearAfterRetry": "이전 메시지를 다시 시도한 다음 /clear를 실행하세요.", "compactAfterRetry": "이전 메시지를 다시 시도한 다음 /compact를 실행하세요.", + "clearAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /clear를 실행하세요.", + "compactAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /compact를 실행하세요.", "optionRejected": "에이전트가 이 설정을 받아들이지 않았습니다.", "goalsUnsupported": "이 에이전트는 목표를 지원하지 않습니다.", "agentRefused": "에이전트가 이를 거부했습니다.", @@ -18056,6 +18060,7 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", + "pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 10c8eb4efca6..4bbb2aa0948e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17561,6 +17561,8 @@ "notDeliveredSendAgain": "此消息未送达。请重新发送以继续。", "commandRefused": "此命令未运行。", "commandRefusedTryAgain": "此命令未运行。请重试。", + "runCommandWhenDone": "完成后再运行 /{{command}}。", + "commandAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /{{command}}。", "compactionFailed": "压缩失败。", "compactionFailedQuoted": "压缩失败:{{detail}}。", "compactionUnconfirmed": "未确认压缩是否完成。", @@ -17627,6 +17629,8 @@ "compactAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /compact。", "clearAfterRetry": "请先重试您之前的消息,然后再运行 /clear。", "compactAfterRetry": "请先重试您之前的消息,然后再运行 /compact。", + "clearAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /clear。", + "compactAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /compact。", "optionRejected": "智能体未接受此设置。", "goalsUnsupported": "此智能体不支持目标。", "agentRefused": "智能体拒绝了此请求。", @@ -18021,6 +18025,7 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", + "pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", 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..07f660c4e3cf --- /dev/null +++ b/src/shared/agent-session-command-refusal-words.ts @@ -0,0 +1,37 @@ +// 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 + +const WORDS_BY_REASON: Partial> = { + backgroundTasksRunning: (say) => + joinSentences([say('backgroundTasksRunning'), say('waitForBackgroundTasks')]), + handoffInFlight: (say) => joinSentences([say('agentStarting'), say('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-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 1e555c676d64..3ecd21e584f4 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 @@ -68,6 +73,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 74e6e543b533..fa80cccbdb0e 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -25,6 +25,7 @@ import { type AgentSessionFailureSay } from './agent-session-failure-copy' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import { commandRefusedByReason } from './agent-session-command-refusal-words' import { joinSentences } from './sentence-joining' import { DISPATCH_REJECTED_CANCELLED, @@ -236,8 +237,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-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 3f5999105e57..95ada8c910cc 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, @@ -48,23 +53,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: 'The agent is still working.', + 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.', optionRejected: "The agent didn't accept this setting.", goalsUnsupported: "This agent doesn't support goals.", agentRefused: 'The agent turned this down.', diff --git a/src/shared/returned-draft-text.test.ts b/src/shared/returned-draft-text.test.ts index 79f27507cde0..57b0181107e3 100644 --- a/src/shared/returned-draft-text.test.ts +++ b/src/shared/returned-draft-text.test.ts @@ -20,4 +20,12 @@ 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 slash 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') + }) }) diff --git a/src/shared/returned-draft-text.ts b/src/shared/returned-draft-text.ts index 4dc67520d7fa..824bfdcdf12e 100644 --- a/src/shared/returned-draft-text.ts +++ b/src/shared/returned-draft-text.ts @@ -1,7 +1,11 @@ +const LONE_SLASH_COMMAND = /^\/[^\s/]+$/ + /** * 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 slash command such as + * `/compact`, 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 === '' || LONE_SLASH_COMMAND.test(kept.trim())) { return text } if (kept === returned || kept.endsWith(`\n\n${returned}`)) { 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}` } + } +} From f4a8fbf82d71fa981d6f4927fe8c1eb5c2baee41 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:11:43 -0700 Subject: [PATCH 10/27] fix(native-chat): a recorded send is no sending card; queue lists land at once; a spent card's wait says to run it again; only /compact or /clear give way to returned text --- ...tured-agent-session-queued-compact.test.ts | 4 +- ...uctured-agent-session-queued-cards.test.ts | 23 ++++++ .../structured-agent-session-queued-cards.ts | 14 +++- ...tured-agent-session.queued-gating.test.tsx | 38 +++++++++- .../use-structured-agent-session.ts | 11 ++- .../agent-session-command-refusal-words.ts | 18 ++++- src/shared/returned-draft-text.test.ts | 5 +- src/shared/returned-draft-text.ts | 8 +- ...structured-agent-session-coalescer.test.ts | 74 +++++++++---------- .../structured-agent-session-coalescer.ts | 10 +-- .../structured-agent-session-composer.ts | 10 +++ 11 files changed, 149 insertions(+), 66 deletions(-) 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 index d2051423eeda..ab873b8c6879 100644 --- 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 @@ -287,7 +287,7 @@ describe('a /compact that waits in line', () => { 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.' + text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.' }) expect(cards).toEqual([]) }) @@ -318,7 +318,7 @@ describe('a /compact that waits in line', () => { 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.' + text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.' }) ) expect((await rig.handoff(compactId))?.handedOverAt).toBeUndefined() 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 4924cbf61fc2..b5c76027904e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { newestSteerableQueuedMessageCard, outboxOutsideQueuedCards, + outboxQueueSendsOnTheirWay, projectQueuedMessageCards } from './structured-agent-session-queued-cards' @@ -228,6 +229,28 @@ describe('queued message cards', () => { expect(ids(outboxOutsideQueuedCards(unconfirmed, ['a'], true, QUEUEING))).toEqual(['b']) }) + it('a queue send is on its way only while the host has no record of it', () => { + const entry: StructuredAgentSessionOutboxEntry = { + clientMessageId: 'a', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'a' }] }, + previewUris: [], + state: 'dispatching', + queuedAt: 1, + lastAttemptAt: 2, + retryAfterUnknownSubmittedAt: null, + sentDelivery: 'queue-if-active' + } + const onItsWay = (submissions: AgentJournalSubmission[]) => + outboxQueueSendsOnTheirWay([entry], [], true, QUEUEING, submissions).map( + (candidate) => candidate.clientMessageId + ) + expect(onItsWay([])).toEqual(['a']) + // Taken straight through, or handed off from its card: the journal draws it now. + expect(onItsWay([submission('a')])).toEqual([]) + expect(onItsWay([handOff('a')])).toEqual([]) + }) + it('hides a send only by what its request carries: a plain one is always a bubble', () => { const entry = ( clientMessageId: string, 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 71f4821f488e..9c4f3e1373de 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 @@ -142,22 +142,28 @@ export function outboxOutsideQueuedCards( host: StructuredAgentSessionQueueDelivery ): readonly StructuredAgentSessionOutboxEntry[] { const held = new Set(heldIds) - const onItsWay = new Set(outboxQueueSendsOnTheirWay(outbox, heldIds, isWorking, host)) + // Hidden from the transcript whether or not the host has recorded it yet. + const onItsWay = new Set(outboxQueueSendsOnTheirWay(outbox, heldIds, isWorking, host, [])) const next = outbox.filter((entry) => !held.has(entry.clientMessageId) && !onItsWay.has(entry)) return next.length === outbox.length ? outbox : next } -/** The queue sends on their way that the host holds no card for yet: shown as sending cards. */ +/** The queue sends on their way that the host has no record of yet — no card, and no submission + * of their own or handed off from one: shown as sending cards. */ export function outboxQueueSendsOnTheirWay( outbox: readonly StructuredAgentSessionOutboxEntry[], heldIds: readonly string[], isWorking: boolean, - host: StructuredAgentSessionQueueDelivery + host: StructuredAgentSessionQueueDelivery, + submissions: readonly AgentJournalSubmission[] ): StructuredAgentSessionOutboxEntry[] { if (!isWorking) { return [] } - const held = new Set(heldIds) + const held = new Set([...heldIds, ...handedOffQueuedMessageIds(submissions)]) + for (const submission of submissions) { + held.add(submission.clientMessageId) + } const admission = admitStructuredAgentSessionOutboxEntry(outbox) const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 return outbox.filter( 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 310fe4535dd0..071bbedbaa94 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,7 +9,10 @@ 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 { createStructuredAgentSessionOutboxEntry, @@ -26,6 +29,7 @@ const mocks = vi.hoisted(() => ({ })) let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined +let submissions: AgentJournalSubmission[] = [] let outboxEntries: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -38,7 +42,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions: [], + submissions, status: 'ready', error: null, hasOlder: false, @@ -131,6 +135,7 @@ beforeEach(() => { ) items = [RUNNING_TURN] queuedMessages = undefined + submissions = [] outboxEntries = [] localStorage.clear() clearNativeChatDraftCacheForTests() @@ -483,6 +488,35 @@ describe('a /compact against a host that holds commands in line', () => { expect(commandCalls()).toHaveLength(0) }) + it('an idle send the host has recorded is its transcript row only, never a sending card too', () => { + items = [] + outboxEntries = [ + unsent('recorded', { + state: 'dispatching', + lastAttemptAt: 2, + sentDelivery: '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 = [] outboxEntries = [ 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 5f3955930f5d..abbef64ce01f 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 @@ -194,8 +194,15 @@ export function useStructuredAgentSession(args: { const isWorking = transportState.isWorking // Hidden from the transcript, a queue send on its way reads as sending among the cards. const queueSendsOnTheirWay = useMemo( - () => outboxQueueSendsOnTheirWay(outbox, queuedMessageIds, isWorking, queueDelivery), - [isWorking, outbox, queueDelivery, queuedMessageIds] + () => + outboxQueueSendsOnTheirWay( + outbox, + queuedMessageIds, + isWorking, + queueDelivery, + transportState.submissions + ), + [isWorking, outbox, queueDelivery, queuedMessageIds, transportState.submissions] ) const transcriptOutbox = useMemo( () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery), diff --git a/src/shared/agent-session-command-refusal-words.ts b/src/shared/agent-session-command-refusal-words.ts index 07f660c4e3cf..ec85fe3b9947 100644 --- a/src/shared/agent-session-command-refusal-words.ts +++ b/src/shared/agent-session-command-refusal-words.ts @@ -16,10 +16,22 @@ const STILL_WORKING: ReasonWords = (say, 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: (say) => - joinSentences([say('backgroundTasksRunning'), say('waitForBackgroundTasks')]), - handoffInFlight: (say) => joinSentences([say('agentStarting'), say('waitForStart')]), + 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) diff --git a/src/shared/returned-draft-text.test.ts b/src/shared/returned-draft-text.test.ts index 57b0181107e3..9899d7912ca5 100644 --- a/src/shared/returned-draft-text.test.ts +++ b/src/shared/returned-draft-text.test.ts @@ -21,11 +21,14 @@ describe('appendReturnedDraftText', () => { expect(appendReturnedDraftText('say ping', 'ping')).toBe('say ping\n\nping') }) - it('a lone slash command gives way to the message handed back, which it could not carry', () => { + 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 824bfdcdf12e..b60388fe84f8 100644 --- a/src/shared/returned-draft-text.ts +++ b/src/shared/returned-draft-text.ts @@ -1,9 +1,9 @@ -const LONE_SLASH_COMMAND = /^\/[^\s/]+$/ +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), except a lone slash command such as - * `/compact`, which takes no text after it and was never sent: the message takes its place, so + * 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. */ @@ -13,7 +13,7 @@ export function appendReturnedDraftText(draft: string, text: string): string { return draft } const kept = draft.trimEnd() - if (kept === '' || LONE_SLASH_COMMAND.test(kept.trim())) { + if (kept === '' || isLoneStructuredAgentSessionConversationCommand(kept)) { return text } if (kept === returned || kept.endsWith(`\n\n${returned}`)) { diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 9d34b8db3825..9bc805df6f6d 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -69,60 +69,52 @@ 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 - } - ] - - coalescer.push({ ...batch(1), queuedMessages: list }) - coalescer.push(batch(2)) + 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: [draft], queuePause: { reason: 'restarted' } }) + 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: 'restarted' } + }) + 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: 'restarted' } }) - coalescer.push(batch(2)) - coalescer.flush() - expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'restarted' } }) - - coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) - coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) - coalescer.flush() - expect(events[1]).toMatchObject({ queuePause: { reason: 'stopped' } }) + // 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() }) }) diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index 8564056ce048..c71e97d1f501 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -5,6 +5,9 @@ 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') ) } @@ -28,13 +31,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 - ? { queuedMessages: right.queuedMessages, queuePause: right.queuePause ?? null } - : left.queuedMessages !== undefined - ? { queuedMessages: left.queuedMessages, queuePause: left.queuePause ?? null } - : {}), sessionId: right.sessionId, batch: { cursor: right.batch.cursor, diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 9b60920ac6bf..ab3f86e3073b 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -105,6 +105,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' From 185781d95fc4b80ead478abb8fae848d15651994 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:20:38 -0700 Subject: [PATCH 11/27] refactor(native-chat): derive the sending cards inside the queued-messages hook --- ...tructured-agent-session-queued-messages.ts | 37 ++++++++++++++++--- .../use-structured-agent-session.ts | 20 ++-------- 2 files changed, 35 insertions(+), 22 deletions(-) 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 6b034d4e6806..297084ab2a28 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,10 +17,12 @@ import type { } from '../../../../shared/agent-session-wire' import { appendNativeChatDraftCache } from './native-chat-draft-cache' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import type { StructuredAgentSessionQueueDelivery } from '../../../../shared/structured-agent-session-outbox-delivery' import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + outboxQueueSendsOnTheirWay, sendingQueuedMessageCards, type QueuedMessageCard } from './structured-agent-session-queued-cards' @@ -45,6 +47,10 @@ export type StructuredAgentSessionQueuedMessagesController = { } const NO_SENDS: readonly StructuredAgentSessionOutboxEntry[] = [] +const NO_DELIVERY: StructuredAgentSessionQueueDelivery = { + capability: 'unsupported', + enabled: false +} function alreadySentNotice(): void { toast.error( @@ -61,15 +67,19 @@ export function useStructuredAgentSessionQueuedMessages(args: { hasPendingPrompt: boolean /** The chat shows the agent working: a command card offers no send then. */ agentWorking?: boolean - /** Queue sends on their way that the host holds no card for yet. */ - sending?: readonly StructuredAgentSessionOutboxEntry[] + /** This pane's outbox, whose queue sends the host has no record of yet show as sending. */ + sending?: { + outbox: readonly StructuredAgentSessionOutboxEntry[] + isWorking: boolean + queueDelivery: StructuredAgentSessionQueueDelivery + } composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args const agentWorking = args.agentWorking === true const pause = args.queuePause - const sending = args.sending ?? NO_SENDS + const { outbox = NO_SENDS, isWorking = false, queueDelivery = NO_DELIVERY } = args.sending ?? {} const cards = useMemo( () => [ @@ -78,9 +88,26 @@ export function useStructuredAgentSessionQueuedMessages(args: { agentWorking, queuePaused: pause !== null }), - ...sendingQueuedMessageCards(sending) + ...sendingQueuedMessageCards( + outboxQueueSendsOnTheirWay( + outbox, + (queuedMessages ?? []).map((message) => message.messageId), + isWorking, + queueDelivery, + submissions + ) + ) ], - [agentWorking, hasPendingPrompt, pause, queuedMessages, sending, submissions] + [ + agentWorking, + hasPendingPrompt, + isWorking, + outbox, + pause, + queueDelivery, + queuedMessages, + submissions + ] ) const cardsRef = useRef(cards) useEffect(() => { 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 abbef64ce01f..33b4fa984abe 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 @@ -33,10 +33,7 @@ import { useStructuredAgentSessionContextUsage } from './use-structured-agent-se import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' import { structuredConversationCommandRunner } from './structured-conversation-command-send' -import { - outboxOutsideQueuedCards, - outboxQueueSendsOnTheirWay -} from './structured-agent-session-queued-cards' +import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' @@ -192,18 +189,6 @@ export function useStructuredAgentSession(args: { hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking - // Hidden from the transcript, a queue send on its way reads as sending among the cards. - const queueSendsOnTheirWay = useMemo( - () => - outboxQueueSendsOnTheirWay( - outbox, - queuedMessageIds, - isWorking, - queueDelivery, - transportState.submissions - ), - [isWorking, outbox, queueDelivery, queuedMessageIds, transportState.submissions] - ) const transcriptOutbox = useMemo( () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery), [isWorking, outbox, queueDelivery, queuedMessageIds] @@ -229,7 +214,8 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, agentWorking: transportState.turnId !== null || transportState.isWorking, - sending: queueSendsOnTheirWay, + // Hidden from the transcript, a queue send on its way reads as sending among the cards. + sending: { outbox, isWorking, queueDelivery }, composerScopeKey, mutate }) From 234f7c5ae5b07a493557914b05f78082fbf1bb7d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:31:11 -0700 Subject: [PATCH 12/27] fix(native-chat): keep protocol-version and the agent-session params under the line limit; the send capabilities get their own module --- ...tured-agent-session-queued-compact.test.ts | 12 ++++- ...agent-session-send-runtime-capabilities.ts | 52 +++++++++++++++++++ src/shared/protocol-version.ts | 48 ++--------------- .../structured-agent-session-params.ts | 6 +-- 4 files changed, 67 insertions(+), 51 deletions(-) create mode 100644 src/shared/agent-session-send-runtime-capabilities.ts 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 index ab873b8c6879..9d03df2c9c32 100644 --- 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 @@ -391,11 +391,19 @@ describe('a /compact that waits in line', () => { }) }) -it('only a /compact may ask to wait; a /clear never queues', () => { +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: 'clear', delivery: 'queue-if-active' })).toBe(false) 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/shared/agent-session-send-runtime-capabilities.ts b/src/shared/agent-session-send-runtime-capabilities.ts new file mode 100644 index 000000000000..e40a3a9f2faa --- /dev/null +++ b/src/shared/agent-session-send-runtime-capabilities.ts @@ -0,0 +1,52 @@ +// The capabilities that gate how a send is answered: when the host answers it, what its refusal +// proves, and whether it may wait as a queued card. + +// Why: older structured clients render durable pending replies as uncertain delivery. Capable +// clients skip the host's bounded best-effort settlement observation. +export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = + 'agent-session.pending-send-result.v1' as const +// Why: a send is now answered once the host accepts it, before any agent has it. A client without +// this cannot show a message rejected after that answer, so the host holds its reply until the +// message is handed over or rejected. Transitional: drop the hold once no supported desktop or +// mobile client lacks the capability; mobile must first show a rejected message in place. +export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = + 'agent-session.accepted-send.v1' as const +// Why: a host advertising this answers a resent send id from its record before anything else may +// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown +// refusal included (host not installed, journal database won't open, host disabled). Reading a +// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — +// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was +// written. `agent_session_operation_expired` — only the transcript can tell. An +// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, +// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is +// gone or unreadable on this host). Any other — the chat holds no message under that id and none +// is in flight, but a resend of that id may still run as a new send, so a client that hands the +// text back must not resend the old id. An older host may refuse an id it recorded: none of this +// holds there. +export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = + 'agent-session.send-answers-proof.v1' as const +// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a +// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON +// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer +// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. +// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and +// never compares a draft id with a submission id. It also publishes the queue's pause once, as +// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next +// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant +// gates the rollout. +export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = + 'agent-session.queued-messages.v1' as const +// Why: `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 + +/** Advertised in this order inside RUNTIME_CAPABILITIES; queued-messages stays dark. */ +export const AGENT_SESSION_SEND_RUNTIME_CAPABILITIES = [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ac4dbb629b5f..c534ad0ef6bd 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -14,6 +14,7 @@ export { } from './agent-session-resume-runtime-capabilities' 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 { AGENT_SESSION_SEND_RUNTIME_CAPABILITIES } from './agent-session-send-runtime-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -177,6 +178,7 @@ export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' export * from './agent-session-stop-capabilities' +export * from './agent-session-send-runtime-capabilities' export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const // Older launch schemas reject unknown fields; advertise before clients send keyboard support. @@ -194,47 +196,6 @@ export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.struct // for it (released phones), so the host keeps answering that with its own setting. export const STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY = 'agent-session.structured.client-launch-mode.v1' as const -// Why: older structured clients render durable pending replies as uncertain delivery. Capable -// clients skip the host's bounded best-effort settlement observation. -export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = - 'agent-session.pending-send-result.v1' as const -// Why: a send is now answered once the host accepts it, before any agent has it. A client without -// this cannot show a message rejected after that answer, so the host holds its reply until the -// message is handed over or rejected. Transitional: drop the hold once no supported desktop or -// mobile client lacks the capability; mobile must first show a rejected message in place. -export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = - 'agent-session.accepted-send.v1' as const -// Why: a host advertising this answers a resent send id from its record before anything else may -// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown -// refusal included (host not installed, journal database won't open, host disabled). Reading a -// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — -// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was -// written. `agent_session_operation_expired` — only the transcript can tell. An -// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, -// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is -// gone or unreadable on this host). Any other — the chat holds no message under that id and none -// is in flight, but a resend of that id may still run as a new send, so a client that hands the -// text back must not resend the old id. An older host may refuse an id it recorded: none of this -// holds there. -export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = - 'agent-session.send-answers-proof.v1' as const -// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a -// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON -// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer -// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. -// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and -// never compares a draft id with a submission id. It also publishes the queue's pause once, as -// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next -// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant -// gates the rollout. -export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = - 'agent-session.queued-messages.v1' as const -// Why: `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 = @@ -411,12 +372,9 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // 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_SEND_RUNTIME_CAPABILITIES, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 84cfe4d3d01d..50d574d3002f 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -319,13 +319,11 @@ export const ConversationCommandParams = z 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. */ + * `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() - .refine((value) => value.delivery === undefined || value.command === 'compact', { - message: 'Only /compact can wait in the queue' - }) export const ThreadGoalParams = z .object({ From c1ff9f7dc024d7535d686a10e4bf66f15430d195 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:19:32 -0700 Subject: [PATCH 13/27] fix(native-chat): with the agent idle, a command behind this window's own unsent message says it is still being sent --- .../structured-conversation-command-send.test.ts | 6 +++++- .../structured-conversation-command-send.ts | 6 +++++- ...tructured-agent-session.queued-gating.test.tsx | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) 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 8a41b8b83306..242eb4ceed04 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 @@ -220,7 +220,11 @@ describe('a /compact the host holds in line', () => { 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') - expect(structuredConversationCommandHold({ ...idle, outboxUnsent: true })).toBe('working') + // The agent idle, only this window's own message still on its way: it is still being sent. + expect(structuredConversationCommandHold({ ...idle, outboxUnsent: true })).toBe('sending') + expect( + structuredConversationCommandHold({ ...idle, agentWorking: true, outboxUnsent: true }) + ).toBe('working') expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt') // Only a failed message waits for its Retry: the agent is not working. expect(structuredConversationCommandHold({ ...idle, outboxRetry: true })).toBe('retry') 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 8815b4f19b94..aecb2e35fa53 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 @@ -60,9 +60,13 @@ export function structuredConversationCommandHold(input: { if (input.promptPending) { return 'prompt' } - if (input.agentWorking || input.outboxUnsent) { + 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.outboxUnsent) { + return 'sending' + } // The agent is not working: what is left is said as its row says it. if (input.outboxRetry) { return 'retry' 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 071bbedbaa94..4b067b6a3a55 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 @@ -538,6 +538,21 @@ describe('a /compact against a host that holds commands in line', () => { }) }) + it('/clear with the agent idle behind its own unsent message says it is still being sent', async () => { + items = [] + outboxEntries = [unsent('on-its-way')] + 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.' + }) + expect(commandCalls()).toHaveLength(0) + }) + it('/clear behind only a failed message names its Retry, not the agent working', async () => { items = [] outboxEntries = [unsent('failed', { lastAttemptAt: 2, lastFailure: { kind: 'failed' } })] From f91a6809a0531b41cd47dcaae8b78afbba8e0bc0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:02:03 -0700 Subject: [PATCH 14/27] fix(native-chat): the queued-commands capability stays a plain constant beside the others; main's send capabilities back to its text --- ...agent-session-send-runtime-capabilities.ts | 52 ------------------- src/shared/protocol-version.ts | 51 ++++++++++++++++-- 2 files changed, 48 insertions(+), 55 deletions(-) delete mode 100644 src/shared/agent-session-send-runtime-capabilities.ts diff --git a/src/shared/agent-session-send-runtime-capabilities.ts b/src/shared/agent-session-send-runtime-capabilities.ts deleted file mode 100644 index e40a3a9f2faa..000000000000 --- a/src/shared/agent-session-send-runtime-capabilities.ts +++ /dev/null @@ -1,52 +0,0 @@ -// The capabilities that gate how a send is answered: when the host answers it, what its refusal -// proves, and whether it may wait as a queued card. - -// Why: older structured clients render durable pending replies as uncertain delivery. Capable -// clients skip the host's bounded best-effort settlement observation. -export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = - 'agent-session.pending-send-result.v1' as const -// Why: a send is now answered once the host accepts it, before any agent has it. A client without -// this cannot show a message rejected after that answer, so the host holds its reply until the -// message is handed over or rejected. Transitional: drop the hold once no supported desktop or -// mobile client lacks the capability; mobile must first show a rejected message in place. -export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = - 'agent-session.accepted-send.v1' as const -// Why: a host advertising this answers a resent send id from its record before anything else may -// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown -// refusal included (host not installed, journal database won't open, host disabled). Reading a -// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — -// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was -// written. `agent_session_operation_expired` — only the transcript can tell. An -// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, -// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is -// gone or unreadable on this host). Any other — the chat holds no message under that id and none -// is in flight, but a resend of that id may still run as a new send, so a client that hands the -// text back must not resend the old id. An older host may refuse an id it recorded: none of this -// holds there. -export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = - 'agent-session.send-answers-proof.v1' as const -// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a -// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON -// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer -// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. -// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and -// never compares a draft id with a submission id. It also publishes the queue's pause once, as -// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next -// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant -// gates the rollout. -export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = - 'agent-session.queued-messages.v1' as const -// Why: `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 - -/** Advertised in this order inside RUNTIME_CAPABILITIES; queued-messages stays dark. */ -export const AGENT_SESSION_SEND_RUNTIME_CAPABILITIES = [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY -] as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 9bf30db8bd93..22e452584623 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -29,7 +29,6 @@ export { } from './agent-session-resume-runtime-capabilities' 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 { AGENT_SESSION_SEND_RUNTIME_CAPABILITIES } from './agent-session-send-runtime-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -174,7 +173,6 @@ export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' export * from './agent-session-stop-capabilities' -export * from './agent-session-send-runtime-capabilities' export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const // Older launch schemas reject unknown fields; advertise before clients send keyboard support. @@ -192,6 +190,47 @@ export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.struct // for it (released phones), so the host keeps answering that with its own setting. export const STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY = 'agent-session.structured.client-launch-mode.v1' as const +// Why: older structured clients render durable pending replies as uncertain delivery. Capable +// clients skip the host's bounded best-effort settlement observation. +export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = + 'agent-session.pending-send-result.v1' as const +// Why: a send is now answered once the host accepts it, before any agent has it. A client without +// this cannot show a message rejected after that answer, so the host holds its reply until the +// message is handed over or rejected. Transitional: drop the hold once no supported desktop or +// mobile client lacks the capability; mobile must first show a rejected message in place. +export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = + 'agent-session.accepted-send.v1' as const +// Why: a host advertising this answers a resent send id from its record before anything else may +// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown +// refusal included (host not installed, journal database won't open, host disabled). Reading a +// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — +// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was +// written. `agent_session_operation_expired` — only the transcript can tell. An +// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, +// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is +// gone or unreadable on this host). Any other — the chat holds no message under that id and none +// is in flight, but a resend of that id may still run as a new send, so a client that hands the +// text back must not resend the old id. An older host may refuse an id it recorded: none of this +// holds there. +export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = + 'agent-session.send-answers-proof.v1' as const +// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a +// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON +// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer +// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. +// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and +// never compares a draft id with a submission id. It also publishes the queue's pause once, as +// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next +// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant +// gates the rollout. +export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = + 'agent-session.queued-messages.v1' as const +// Why: `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 = @@ -234,6 +273,8 @@ export const AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY = 'agent-session.conversation-outline.v1' as const // The RPC is registered unconditionally; per-session rewind support is a separate check. export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const +// Why: the rewind UI relies on the next send settling an in-doubt rewind, even with the agent up. +export const AGENT_SESSION_REWIND_RECOVERY_CAPABILITY = 'agent-session.rewind-recovery.v1' as const // Readers must understand a monitoring roster with no available stop control. // Why: a `turn` journal item replaced the status row that used to carry a turn's lifecycle. A // client that predates it would render the unknown kind as text, so the host publishes the legacy @@ -360,9 +401,12 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // turn starts, so a client may gate on either. - ...AGENT_SESSION_SEND_RUNTIME_CAPABILITIES, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, @@ -370,6 +414,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_COMPLETION_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, From ab0b289f23044e9a112ea21762b60f6baf2ede0b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:19:51 -0700 Subject: [PATCH 15/27] test(native-chat): keep the rewind suite under its line limit after main's growth --- .../structured-agent-session-rewind.test.ts | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) 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 6449db19905f..7baf4969f7b4 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 @@ -322,28 +322,25 @@ describe('host rewind', () => { const target = await seed() const body = hostTestMessage('unanswered') const clientOperationId = hostTestOperationId() - expect( - await host.send(caller, { - body, - envelope: { - ...(await params(target)).envelope, - clientOperationId, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: HOST_TEST_SESSION, - fields: { body } - }) - } - }) - ).toMatchObject({ ok: true }) + // 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. - await vi.waitFor(async () => - expect( - (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientOperationId - )?.dispatchState - ).toBe('unknown') - ) + 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' } From 47f9858b73095ac9a5bcbf22a76e7f268370e495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:41:30 -0700 Subject: [PATCH 16/27] fix(native-chat): one import per module in the command send; a rewind on its way holds commands, tested --- .../structured-conversation-command-send.ts | 6 ++-- ...tured-agent-session.queued-gating.test.tsx | 36 ++++++++++++++++++- .../use-structured-agent-session.ts | 2 +- 3 files changed, 40 insertions(+), 4 deletions(-) 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 36e32431bb47..9f419c89f385 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 @@ -16,11 +16,13 @@ import { structuredAgentSessionDeliveryNotices, structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import type { StructuredPromptItem } from './structured-agent-session-message-projection' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { structuredAgentSessionEntryRejectedByHost, type StructuredAgentSessionOutboxEntry 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 4b067b6a3a55..ab7e1b5e4e89 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 @@ -25,7 +25,9 @@ import { structuredAgentSessionDeliveryNotices } from './structured-agent-sessio const mocks = vi.hoisted(() => ({ call: vi.fn(), outboxArgs: Array.of<{ queueDelivery?: { capability: string; enabled: boolean } }>(), - operations: 0 + operations: 0, + /** A rewind this pane started is on its way. */ + rewindPending: false })) let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined @@ -53,6 +55,20 @@ 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-outbox', () => ({ structuredSessionOperationId: () => `operation-${++mocks.operations}`, useStructuredAgentSessionOutbox: (args: { @@ -122,6 +138,7 @@ function cancels(): unknown[] { beforeEach(() => { vi.clearAllMocks() mocks.outboxArgs.length = 0 + mocks.rewindPending = false mocks.call.mockImplementation(async (_target, method) => method === 'agentSession.cancel' ? { @@ -583,6 +600,23 @@ describe('a /compact against a host that holds commands in line', () => { 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) + }) + it('a send behind a waiting command card queues, even with follow-ups off', () => { queuedMessages = [ { 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 7f65c2c7db2a..fd0ade22d176 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 @@ -164,7 +164,7 @@ export function useStructuredAgentSession(args: { }) const { outbox } = outboxController - // 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 || From 70246d8b3b3c1745b5a9973341072da094ca5c7b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:46:34 -0700 Subject: [PATCH 17/27] test(native-chat): type the rewind module mock with a type import --- .../use-structured-agent-session.queued-gating.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ab7e1b5e4e89..9c36523acaf0 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 @@ -21,6 +21,7 @@ import { import { withdrawUnsentStructuredAgentSessionOutboxEntries } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' 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(), @@ -57,7 +58,7 @@ 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() + const actual = await importOriginal() return { ...actual, useStructuredAgentSessionRewind: ( From de818d7be40c97ef42a45fcbe53189694c247beb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:12:30 -0700 Subject: [PATCH 18/27] test(native-chat): a command card cut short by a restart waits or is kept like any card; kept-card captions name only controls shown --- ...le-structured-queued-message-cards.test.ts | 20 +++++++ ...tured-agent-session-queued-compact.test.ts | 60 +++++++++++++++++++ .../NativeChatQueuedMessageList.test.tsx | 16 +++++ 3 files changed, 96 insertions(+) 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 ca69ace08b60..b8e4d15892f3 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -68,6 +68,26 @@ describe('mobileQueuedMessageCards', () => { ).toMatchObject({ command: true, waitsForAgent: true }) }) + it("a kept command card's caption names Send only when Send is there", () => { + const kept = draft({ + messageId: 'c', + paused: true, + pausedReason: QUEUED_MESSAGE_PAUSED_KEPT, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + }) + expect( + mobileQueuedMessageCards([kept], [], { pendingPrompt: false, agentWorking: true })[0]?.caption + ).toBe('Not sent yet — tap Send to send it once the agent finishes') + expect(mobileQueuedMessageCards([kept], [], { pendingPrompt: false })[0]?.caption).toBe( + 'Not sent yet — tap Send to send it' + ) + }) + it("a send-failed command card's caption names Send only when Send is there", () => { const failed = draft({ messageId: 'c', 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 index 9d03df2c9c32..f79102c0d042 100644 --- 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 @@ -5,6 +5,7 @@ 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 { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' import { @@ -355,6 +356,65 @@ describe('a /compact that waits in line', () => { 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 never finishes: the hand-off stays queued. + await rig.host.close(SESSION, 'evict') + rig.awaitStarted.mockImplementation(() => new Promise(() => undefined)) + const startsBefore = rig.awaitStarted.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.awaitStarted.mock.calls.length).toBeGreaterThan(startsBefore)) + rig.crashRestartHostProcess() + 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. + expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(rig.compact).not.toHaveBeenCalled() + }) + + it('cut short by a restart after the person sent it: kept, never spent', async () => { + const compactId = await consumedThenCrashed('sendNow') + expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting', paused: true }]) + const card = rig.host + .collaboratorsForTests() + .sessions.get(SESSION) + ?.journal.queuedMessages.get(compactId) + expect(card).toMatchObject({ + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + body: { command: { name: 'compact' } } + }) + expect(rig.compact).not.toHaveBeenCalled() + }) + + 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') + rig.awaitStarted.mockImplementation(() => new Promise(() => undefined)) + const startsBefore = rig.awaitStarted.mock.calls.length + const { id } = compact() + await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBeGreaterThan(startsBefore)) + rig.crashRestartHostProcess() + // 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() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index e9a9f3b5e360..509ee5ed722b 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -137,6 +137,22 @@ describe('NativeChatQueuedMessageList', () => { expect(screen.queryAllByRole('button')).toHaveLength(0) }) + it("a kept command card's caption names Send only when Send is there, and it never steers", () => { + const kept = { command: true as const, hold: 'paused' as const, pausedReason: 'kept' } + renderList( + controller([ + card({ messageId: 'working', text: '/compact', ...kept, waitsForAgent: true }), + card({ messageId: 'idle', text: '/compact', ...kept }) + ]) + ) + expect( + screen.getByText('Not sent yet — press Send to send it once the agent finishes.') + ).toBeTruthy() + expect(screen.getByText('Not sent yet — press Send to send it.')).toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Send' })).toHaveLength(1) + expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() + }) + 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( From 338a57c898bf50b2734cba77967df460fa9eaf99 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:46:36 -0700 Subject: [PATCH 19/27] fix(native-chat): a held command card, which the queue skips, no longer forces later sends to queue --- .../structured-agent-session-queued-cards.ts | 6 ++-- ...tured-agent-session.queued-gating.test.tsx | 32 ++++++++++++------- .../use-structured-agent-session.ts | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) 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 196f1fd59c71..bddeceb09526 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 @@ -104,12 +104,14 @@ export function projectQueuedMessageCards( }) } -/** A command card waits in line: a later send goes behind it, even with follow-ups off. */ +/** 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.body.command !== undefined + (message) => + message.state === 'waiting' && !message.paused && message.body.command !== undefined ) } 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 9c36523acaf0..1b65bc503cdb 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 @@ -618,24 +618,34 @@ describe('a /compact against a host that holds commands in line', () => { 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: 'kept' as const } : {}) + }) + it('a send behind a waiting command card queues, even with follow-ups off', () => { - queuedMessages = [ - { - ...draft('compact-1'), - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: '/compact' }], - command: { name: 'compact' } - } - } - ] + queuedMessages = [compactCard(false)] render(false) expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ capability: 'supported', enabled: true }) }) + + it('a kept command card, which the queue skips, does not force a send to queue', () => { + queuedMessages = [compactCard(true)] + render(false) + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: false + }) + }) }) describe('a /compact against a host that queues messages but not commands', () => { 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 c918f2b0e15b..8a2ec69e57b3 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 @@ -124,7 +124,7 @@ export function useStructuredAgentSession(args: { ) const prompts = pendingStructuredSessionPrompts(transportState.journalItems) const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts) - // A send after a waiting command goes behind it, even with follow-ups off: send order is kept. + // 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 commandWaiting = commandCardWaiting(transportState.queuedMessages) From 48599fce6e8e7cc47893220fba29ee449abf41dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:37:03 -0700 Subject: [PATCH 20/27] fix(native-chat): a command's refusal line goes once what it waits on is gone A refusal that names something the chat shows (the agent working, a pending prompt, background tasks, an unsent message) is said only while that still holds, derived each render, and dropped once it ends. Other composer errors are unchanged. --- ...turedSession.command-refusal-line.test.tsx | 96 +++++++++++++ ...tiveChatStructuredSession.test-harness.tsx | 6 + .../NativeChatStructuredSession.tsx | 13 +- .../native-chat/native-chat-composer-types.ts | 11 +- ...ative-chat-structured-composer-dispatch.ts | 9 +- ...ructured-conversation-command-send.test.ts | 132 +++++++++++++++--- .../structured-conversation-command-send.ts | 97 +++++++++++-- .../native-chat/use-composer-error-line.ts | 30 ++++ ...se-native-chat-structured-composer-send.ts | 4 +- ...d-agent-session-command-send-gate.test.tsx | 64 ++++++++- ...tured-agent-session.queued-gating.test.tsx | 14 +- .../use-structured-agent-session.ts | 5 +- .../structured-agent-session-composer.ts | 12 +- 13 files changed, 434 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-composer-error-line.ts 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..7a0aef7839fe --- /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.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 8c7363c41151..c53f463d1a9b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -230,6 +230,12 @@ 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, + outbox: outbox.outbox.length > 0 + }, 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 7c8d6cf5d22b..fffb1f6b3291 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -38,6 +38,8 @@ import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-labe import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' +import { useComposerErrorLine } from './use-composer-error-line' +import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' export function NativeChatStructuredSession( @@ -77,11 +79,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, setComposerError] = useState(null) - const [optionPickerRequest, setOptionPickerRequest] = useState<{ - id: string - sequence: number - } | null>(null) + const [composerError, setComposerError] = useComposerErrorLine(controller.commandRefusalCauses) + const [optionPickerRequest, setOptionPickerRequest] = + useState(null) const rootRef = useRef(null) const paneCommands = useStructuredNativeChatPaneCommands({ tabId: props.tabId, @@ -243,7 +243,8 @@ export function NativeChatStructuredSession( props.agent, props.sessionId, props.target, - sendThroughRelaunch + sendThroughRelaunch, + setComposerError ]) return ( 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 f89b90cfe0a9..4240011d5e19 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 @@ -2,7 +2,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 @@ -31,7 +34,11 @@ export type NativeChatStructuredComposerTransport = { worktreeId?: string /** Present only where the host can set this session's goal. */ threadGoal?: { setObjective: (objective: string) => Promise } - onError: (message: string | null) => void + /** `refusedWhile`: a command's refusal, said only while what it waits on still holds. */ + onError: ( + message: string | null, + refusedWhile?: StructuredAgentSessionCommandRefusalCause + ) => void runtime: 'local' | 'remote' /** The session behind this composer; a real user send relinquishes orchestration ownership. */ sessionId: string 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 a620bf710380..e9fe2a5812f7 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,10 +6,10 @@ export async function dispatchNativeChatStructuredComposerText( transport: NativeChatStructuredComposerTransport, text: string, attachments: readonly NativeChatComposerImageAttachment[] = [] -): Promise<{ accepted: boolean; error: string | null }> { - const command = await transport.dispatchCommand(text) - if (command.handled) { - return { accepted: command.accepted, error: command.error } +): Promise> { + const { handled, ...command } = await transport.dispatchCommand(text) + if (handled) { + return command } return { accepted: transport.send(text, attachments), error: null } } 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 242eb4ceed04..dea42e548db4 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 @@ -7,9 +7,17 @@ import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-na import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { sendStructuredConversationCommand, - structuredConversationCommandHold + structuredConversationCommandHold, + type StructuredConversationCommandCauses } from './structured-conversation-command-send' +const NO_CAUSES: StructuredConversationCommandCauses = { + working: false, + prompt: false, + background: false, + outbox: false +} + const START_FAILED: AgentSessionFailureFact = { kind: 'startFailed' } const COMPACTION_FAILED: AgentSessionFailureFact = { kind: 'compactionFailed', @@ -63,6 +71,7 @@ async function sent( agentName: structuredAgentLabel(provider), pending: { current: false }, hold: null, + causes: NO_CAUSES, startFailures: () => [], send: async () => ({ kind: 'done', value: result }) }) @@ -158,6 +167,7 @@ describe('the line under the composer after a conversation command failed', () = agentName: 'Claude', pending: { current: false }, hold: null, + causes: NO_CAUSES, startFailures: () => [START_FAILED], send: async () => ({ kind: 'done', value: result }) }) @@ -233,29 +243,31 @@ describe('a /compact the host holds in line', () => { }) }) -describe('a command held here', () => { - function held( - command: 'clear' | 'compact', - hold: Parameters[0]['hold'], - pending = { current: false } - ) { - 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, - startFailures: () => [], - send - }) - } +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 }) @@ -307,3 +319,79 @@ describe('a command held here', () => { }) }) }) + +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') + for (const hold of ['retry', 'sending'] as const) { + expect((await held('clear', hold, pending, shown('outbox')).result).refusedWhile).toBe( + 'outbox' + ) + } + }) + + 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 9f419c89f385..7065685e58f8 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 @@ -28,6 +28,10 @@ import { type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' +import type { + StructuredAgentSessionCommandOutcome, + StructuredAgentSessionCommandRefusalCause +} from '../../../../shared/structured-agent-session-composer' import type { StructuredAgentSessionWrite, StructuredAgentSessionWriteOutcome @@ -83,6 +87,32 @@ export function structuredConversationCommandHold(input: { return input.outboxSending ? 'sending' : null } +type CommandOutcome = Omit + +/** Which of what a refused command waits on the chat shows right now. */ +export type StructuredConversationCommandCauses = Readonly< + Record +> + +const CAUSE_OF_HOLD: Record< + Exclude, + StructuredAgentSessionCommandRefusalCause +> = { + working: 'working', + prompt: 'prompt', + background: 'background', + retry: 'outbox', + sending: 'outbox' +} + +/** The host's refusal reasons whose words name something the chat shows. */ +const CAUSE_OF_HOST_REFUSAL: Partial> = { + turnActive: 'working', + messagesUnsettled: 'working', + promptPending: 'prompt', + backgroundTasksRunning: 'background' +} + /** The line a command refused here gets: what the person sees and can do, as the host says it. */ function heldCommandText( command: AgentSessionConversationCommand, @@ -116,12 +146,23 @@ export async function sendStructuredConversationCommand(input: { agentName: string pending: { current: boolean } hold: StructuredConversationCommandHold | null + /** What the chat showed at the press. A refusal names its cause only when the chat showed it, + * so the line can't vanish before it is read; 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 }> { +}): 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') } @@ -131,7 +172,7 @@ export async function sendStructuredConversationCommand(input: { return { accepted: false, error: null } } if (input.hold !== null) { - return { accepted: false, error: heldCommandText(input.command, input.hold) } + return refused(heldCommandText(input.command, input.hold), CAUSE_OF_HOLD[input.hold]) } input.pending.current = true try { @@ -155,13 +196,27 @@ 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 } + } + const reason = hostRefusalReason(value) + return refused(error, reason === undefined ? undefined : CAUSE_OF_HOST_REFUSAL[reason]) } finally { input.pending.current = false } } -/** The composer's /clear or /compact, with what this pane knows about work in flight. */ +function hostRefusalReason(result: AgentSessionConversationCommandResult): string | undefined { + const details = isAgentSessionConversationCommand(result.command) + ? readWholeAgentSessionFailureFact(result.failure)?.refusal?.details + : undefined + return details && 'reason' in details && typeof details.reason === 'string' + ? details.reason + : undefined +} + +/** 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 } @@ -171,7 +226,7 @@ export function structuredConversationCommandRunner(args: { chat: { turnId: string | null isWorking: boolean - backgroundTasks: { isMonitoring: 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. */ @@ -182,11 +237,25 @@ export function structuredConversationCommandRunner(args: { /** The loaded rows, read when the reply lands, for the start failures they state. */ items: () => readonly AgentJournalRenderItem[] write: StructuredAgentSessionWrite -}): ( - command: AgentSessionConversationCommand -) => Promise<{ accepted: boolean; error: string | null }> { - return (command) => { - const promptPending = args.prompts.length > 0 +}): { + run: (command: AgentSessionConversationCommand) => Promise + causes: StructuredConversationCommandCauses +} { + const promptPending = args.prompts.length > 0 + const agentWorking = args.chat.turnId !== null || args.chat.isWorking + const rows = outboxRows(args.outbox, args.chat.submissions, args.agentName) + const outboxUnsent = hasUnsentStructuredAgentSessionOutboxEntry( + args.outbox, + args.chat.submissions + ) + 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, + outbox: rows.outboxRetry || rows.outboxSending || outboxUnsent + } + const run = (command: AgentSessionConversationCommand) => { const waitsInLine = command === 'compact' && args.commandsWait && @@ -197,14 +266,15 @@ export function structuredConversationCommandRunner(args: { pending: args.pending, hold: structuredConversationCommandHold({ waitsInLine, - agentWorking: args.chat.turnId !== null || args.chat.isWorking, + 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, - ...outboxRows(args.outbox, args.chat.submissions, args.agentName), - outboxUnsent: hasUnsentStructuredAgentSessionOutboxEntry(args.outbox, args.chat.submissions) + ...rows, + outboxUnsent }), + causes, startFailures: () => structuredAgentSessionStartFailureFacts(args.items()), send: (command) => args.write( @@ -216,6 +286,7 @@ export function structuredConversationCommandRunner(args: { ) }) } + return { run, causes } } /** What this window's own messages offer on their rows: a Retry, or nothing while still on diff --git a/src/renderer/src/components/native-chat/use-composer-error-line.ts b/src/renderer/src/components/native-chat/use-composer-error-line.ts new file mode 100644 index 000000000000..1a855fbf8ca8 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-composer-error-line.ts @@ -0,0 +1,30 @@ +import { useCallback, useState } from 'react' +import type { StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer' +import type { StructuredConversationCommandCauses } from './structured-conversation-command-send' + +type ComposerErrorLine = { + text: string + refusedWhile: StructuredAgentSessionCommandRefusalCause | undefined +} + +/** The line under the composer. A command's refusal names what the chat shows it waiting on, so + * it goes once that is gone; any other error stays until the next send. */ +export function useComposerErrorLine( + causes: StructuredConversationCommandCauses +): [ + string | null, + (text: string | null, refusedWhile?: StructuredAgentSessionCommandRefusalCause) => void +] { + const [line, setLine] = useState(null) + const set = useCallback( + (text: string | null, refusedWhile?: StructuredAgentSessionCommandRefusalCause) => + setLine(text === null ? null : { text, refusedWhile }), + [] + ) + const stands = line !== null && (line.refusedWhile === undefined || causes[line.refusedWhile]) + // Dropped, not hidden: the cause coming back later is not what this refusal was about. + if (line !== null && !stands) { + setLine(null) + } + return [stands ? line.text : null, set] +} 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 25a824b86268..1b97e353e76f 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 @@ -65,8 +65,8 @@ export function useNativeChatStructuredComposerSend({ } const submitted = readNativeChatComposerDraft(draftScopeKey) void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) - .then(({ accepted, error }) => { - structuredTransport.onError(error) + .then(({ accepted, error, refusedWhile }) => { + structuredTransport.onError(error, refusedWhile) if (!accepted) { return } 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 1c5044265a16..9bc36a9b252b 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() @@ -40,6 +42,7 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ })) 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 { @@ -56,6 +59,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. */ @@ -90,3 +94,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.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index 1b65bc503cdb..da144d549e20 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 @@ -552,7 +552,8 @@ describe('a /compact against a host that holds commands in line', () => { }) expect(outcome).toEqual({ accepted: false, - error: 'Your earlier message is still being sent. Run /clear once it has gone.' + error: 'Your earlier message is still being sent. Run /clear once it has gone.', + refusedWhile: 'outbox' }) }) @@ -566,7 +567,8 @@ describe('a /compact against a host that holds commands in line', () => { }) expect(outcome).toEqual({ accepted: false, - error: 'Your earlier message is still being sent. Run /clear once it has gone.' + error: 'Your earlier message is still being sent. Run /clear once it has gone.', + refusedWhile: 'outbox' }) expect(commandCalls()).toHaveLength(0) }) @@ -581,7 +583,8 @@ describe('a /compact against a host that holds commands in line', () => { }) expect(outcome).toEqual({ accepted: false, - error: 'Retry your earlier message, then run /clear.' + error: 'Retry your earlier message, then run /clear.', + refusedWhile: 'outbox' }) expect(commandCalls()).toHaveLength(0) }) @@ -682,13 +685,14 @@ describe('a /compact against a host that holds commands but has its queue dark', it("is exactly today's: held back mid-turn, and idle goes out without `delivery`", async () => { const { result } = render() - let outcome: { accepted: boolean; error: string | null } | undefined + 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." + error: "The agent is still working. Run /compact when it's done.", + refusedWhile: 'working' }) expect(commandCalls()).toHaveLength(0) items = [] 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 8a2ec69e57b3..45182bf7cbf4 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 @@ -181,7 +181,7 @@ export function useStructuredAgentSession(args: { blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, write }) - const runConversationCommand = structuredConversationCommandRunner({ + const conversationCommand = structuredConversationCommandRunner({ agentName: structuredAgentLabel(agent), pending: commandPending, commandsWait, @@ -235,7 +235,8 @@ export function useStructuredAgentSession(args: { epoch: state.epoch, rewind, conversationCommands, - runConversationCommand, + runConversationCommand: conversationCommand.run, + commandRefusalCauses: conversationCommand.causes, journalItems: transcriptItems, subagentRoster: transportState.subagentRoster, messages, diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index ab3f86e3073b..77b803cb030c 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -36,16 +36,26 @@ 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, or a message of this window's not yet gone. */ +export type StructuredAgentSessionCommandRefusalCause = + | 'working' + | 'prompt' + | 'background' + | 'outbox' + 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 { From 2df8b2dcd1c990a1398407cfe29aba4e45061af2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:49:00 -0700 Subject: [PATCH 21/27] fix(mobile): a command's refusal banner goes once what it waits on is gone The phone's banner keeps a refusal naming the agent working or a pending prompt only while the chat still shows it, dropped each render once it ends. Background tasks, which the phone can't see, and generic errors behave as before. The host-reason mapping is shared with desktop. --- ...e-dictation-refused-start-routing.test.tsx | 1 + .../mobile-native-chat-controller-contract.ts | 3 ++ ...mobile-structured-composer-command.test.ts | 42 +++++++++++++-- .../mobile-structured-composer-command.ts | 27 +++++++--- .../use-mobile-native-chat-controller.ts | 5 +- .../use-mobile-native-chat-send-error.test.ts | 53 ++++++++++++++++++- .../use-mobile-native-chat-send-error.ts | 32 +++++++++-- .../use-mobile-native-chat-session-lane.ts | 3 +- ...se-mobile-session-native-chat-dictation.ts | 1 + .../use-mobile-structured-agent-session.ts | 18 ++++++- ...use-mobile-structured-send-with-outcome.ts | 3 +- .../structured-conversation-command-send.ts | 21 +------- ...red-agent-session-command-refusal-cause.ts | 27 ++++++++++ 13 files changed, 197 insertions(+), 39 deletions(-) create mode 100644 src/shared/structured-agent-session-command-refusal-cause.ts 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 90ebecb42ebb..239fad709ab6 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 { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' @@ -41,6 +42,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-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index 555066f78c20..4d62e05da9f2 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -148,20 +148,56 @@ describe('mobile structured conversation commands', () => { 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." + "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." + "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." + "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 5e09645ac1fc..d8012a5555a7 100644 --- a/mobile/src/session/mobile-structured-composer-command.ts +++ b/mobile/src/session/mobile-structured-composer-command.ts @@ -5,8 +5,10 @@ import type { 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' @@ -37,7 +39,8 @@ export async function dispatchMobileStructuredCommand(input: { 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 - onError: (message: string) => void + /** `refusedWhile`: what the phone showed the refused command waiting on. */ + onError: (message: string, refusedWhile?: StructuredAgentSessionCommandRefusalCause) => void timeoutMs: number }): Promise { if (input.pending.current) { @@ -54,10 +57,11 @@ export async function dispatchMobileStructuredCommand(input: { const outcome = await dispatchStructuredAgentSessionComposerCommand(input.text, { ...input.controller, runConversationCommand: async (command) => { + const shown = input.busy() const waitsInLine = input.waitsInLine(command) - const busy = waitsInLine ? null : input.busy() + const busy = waitsInLine ? null : shown if (busy) { - return { accepted: false, error: busyCommandText(command, busy) } + return { accepted: false, error: busyCommandText(command, busy), refusedWhile: busy } } input.pending.current = true try { @@ -81,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/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 3b6c1e23818f..5554d6518f19 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -15,6 +15,7 @@ import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' +import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes' import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution' @@ -38,7 +39,7 @@ export function useMobileNativeChatController(args: { connState: ConnectionState /** Host capability fact from the shared runtime status probe. */ agentSessionHostSupport?: StructuredAgentSessionHostSupport | null - onSendError: (message: string) => void + onSendError: MobileNativeChatSendErrorReporter /** 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 @@ -298,6 +299,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..1d6ac31c018f 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,17 @@ 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. It has no background-task state, so a + * refusal naming those stays as any other failure. */ +export type MobileNativeChatCommandRefusalCauses = Readonly< + Partial> +> const NATIVE_CHAT_SEND_ERROR_HOLD_MS = 4000 const NATIVE_CHAT_SEND_ERROR_TOAST_MS = 1600 @@ -13,12 +26,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 +62,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 +72,7 @@ export function useMobileNativeChatSendError(args: { } clearTimer() setMessage(next) + setRefusedWhile(cause) timerRef.current = setTimeout(() => { timerRef.current = null setMessage(null) @@ -76,5 +95,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 c34f1995190c..37332329d9f8 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 @@ -40,7 +41,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.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index c3f0b9e3bb9d..04efd6fe972e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -37,6 +37,10 @@ import { useMobileStructuredSendWithOutcome, type StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' +import type { + MobileNativeChatCommandRefusalCauses, + MobileNativeChatSendErrorReporter +} from './use-mobile-native-chat-send-error' import { useMobileStructuredQueuedMessageControls, type MobileStructuredQueuedMessageControls @@ -63,6 +67,7 @@ type StructuredMobileSession = ReturnType Promise /** The queued-draft cards and their actions, from any host that publishes them. */ queued: MobileStructuredQueuedMessageControls + commandRefusalCauses: MobileNativeChatCommandRefusalCauses } export function useMobileStructuredAgentSession(args: { @@ -80,7 +85,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 { @@ -198,6 +203,14 @@ export function useMobileStructuredAgentSession(args: { () => state.items.find(pendingStructuredQuestion) ?? null, [state.items] ) + // What a refused command's line on the phone stands on; it has no background-task state. + const commandRefusalCauses = useMemo( + () => ({ + working: turnId !== null, + prompt: approvalPrompt !== null || questionPrompt !== null + }), + [approvalPrompt, questionPrompt, turnId] + ) const queued = useMobileStructuredQueuedMessageControls({ sessionKey, queuedMessages, @@ -262,6 +275,7 @@ export function useMobileStructuredAgentSession(args: { question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, respondQuestion, - queued + queued, + commandRefusalCauses } } 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 5c4ddeeee2ff..b9c3d67334b4 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 { @@ -43,7 +44,7 @@ export function useMobileStructuredSendWithOutcome(args: { StructuredAgentSessionComposerOptions, 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' > - onSendError: (message: string) => void + onSendError: MobileNativeChatSendErrorReporter }): ( text: string, images?: string[], 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 7065685e58f8..5e726c82038e 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 @@ -28,6 +28,7 @@ import { type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' +import { structuredAgentSessionCommandHostRefusalCause } from '../../../../shared/structured-agent-session-command-refusal-cause' import type { StructuredAgentSessionCommandOutcome, StructuredAgentSessionCommandRefusalCause @@ -105,14 +106,6 @@ const CAUSE_OF_HOLD: Record< sending: 'outbox' } -/** The host's refusal reasons whose words name something the chat shows. */ -const CAUSE_OF_HOST_REFUSAL: Partial> = { - turnActive: 'working', - messagesUnsettled: 'working', - promptPending: 'prompt', - backgroundTasksRunning: 'background' -} - /** The line a command refused here gets: what the person sees and can do, as the host says it. */ function heldCommandText( command: AgentSessionConversationCommand, @@ -199,22 +192,12 @@ export async function sendStructuredConversationCommand(input: { if (value.state === 'completed' && !error) { return { accepted: true, error: null } } - const reason = hostRefusalReason(value) - return refused(error, reason === undefined ? undefined : CAUSE_OF_HOST_REFUSAL[reason]) + return refused(error, structuredAgentSessionCommandHostRefusalCause(value)) } finally { input.pending.current = false } } -function hostRefusalReason(result: AgentSessionConversationCommandResult): string | undefined { - const details = isAgentSessionConversationCommand(result.command) - ? readWholeAgentSessionFailureFact(result.failure)?.refusal?.details - : undefined - return details && 'reason' in details && typeof details.reason === 'string' - ? details.reason - : undefined -} - /** 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: { 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 +} From f6480f0575a4e9d87403bdd2b69c94fdea697c7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:02:09 -0700 Subject: [PATCH 22/27] fix(native-chat): a refusal behind an earlier message names whether it is being sent or offers Retry Each line now goes when its own state ends: a send that fails stops being sent, and a resend stops offering Retry. --- ...tiveChatStructuredSession.test-harness.tsx | 3 ++- ...ructured-conversation-command-send.test.ts | 7 +++---- .../structured-conversation-command-send.ts | 21 +++++++------------ ...tured-agent-session.queued-gating.test.tsx | 18 +++++++++++----- .../structured-agent-session-composer.ts | 5 +++-- 5 files changed, 28 insertions(+), 26 deletions(-) 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 c53f463d1a9b..16e292d1630f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -234,7 +234,8 @@ export function createStructuredSessionMocks() { working: mocks.turnId !== null || mocks.isWorking, prompt: mocks.promptItems.length > 0, background: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks, - outbox: outbox.outbox.length > 0 + sending: outbox.outbox.length > 0, + retry: false }, epoch: 'epoch-1', rewind: { surface: undefined }, 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 dea42e548db4..2e0044379803 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 @@ -15,7 +15,8 @@ const NO_CAUSES: StructuredConversationCommandCauses = { working: false, prompt: false, background: false, - outbox: false + sending: false, + retry: false } const START_FAILED: AgentSessionFailureFact = { kind: 'startFailed' } @@ -358,9 +359,7 @@ describe('a refusal names what it waits on only while the chat shows it', () => (await held('compact', 'background', pending, shown('background')).result).refusedWhile ).toBe('background') for (const hold of ['retry', 'sending'] as const) { - expect((await held('clear', hold, pending, shown('outbox')).result).refusedWhile).toBe( - 'outbox' - ) + expect((await held('clear', hold, pending, shown(hold)).result).refusedWhile).toBe(hold) } }) 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 5e726c82038e..42e561127109 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 @@ -95,17 +95,6 @@ export type StructuredConversationCommandCauses = Readonly< Record > -const CAUSE_OF_HOLD: Record< - Exclude, - StructuredAgentSessionCommandRefusalCause -> = { - working: 'working', - prompt: 'prompt', - background: 'background', - retry: 'outbox', - sending: 'outbox' -} - /** The line a command refused here gets: what the person sees and can do, as the host says it. */ function heldCommandText( command: AgentSessionConversationCommand, @@ -140,7 +129,8 @@ export async function sendStructuredConversationCommand(input: { pending: { current: boolean } hold: StructuredConversationCommandHold | null /** What the chat showed at the press. A refusal names its cause only when the chat showed it, - * so the line can't vanish before it is read; else it is said as any other failure. */ + * 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[] @@ -165,7 +155,8 @@ export async function sendStructuredConversationCommand(input: { return { accepted: false, error: null } } if (input.hold !== null) { - return refused(heldCommandText(input.command, input.hold), CAUSE_OF_HOLD[input.hold]) + // 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 { @@ -236,7 +227,9 @@ export function structuredConversationCommandRunner(args: { 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, - outbox: rows.outboxRetry || rows.outboxSending || outboxUnsent + // Apart: a send that fails stops being sent, and a resend stops offering Retry. + sending: rows.outboxSending || outboxUnsent, + retry: rows.outboxRetry } const run = (command: AgentSessionConversationCommand) => { const waitsInLine = 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 da144d549e20..ca46de0dfb31 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 @@ -553,14 +553,14 @@ describe('a /compact against a host that holds commands in line', () => { expect(outcome).toEqual({ accepted: false, error: 'Your earlier message is still being sent. Run /clear once it has gone.', - refusedWhile: 'outbox' + refusedWhile: 'sending' }) }) it('/clear with the agent idle behind its own unsent message says it is still being sent', async () => { items = [] outboxEntries = [unsent('on-its-way')] - const { result } = render() + const { result, rerender } = render() let outcome: unknown await act(async () => { outcome = await result.current.runConversationCommand('clear') @@ -568,15 +568,19 @@ describe('a /compact against a host that holds commands in line', () => { expect(outcome).toEqual({ accepted: false, error: 'Your earlier message is still being sent. Run /clear once it has gone.', - refusedWhile: 'outbox' + refusedWhile: 'sending' }) expect(commandCalls()).toHaveLength(0) + // Its send fails: no longer being sent, so that line goes; the row now offers Retry. + outboxEntries = [unsent('on-its-way', { lastAttemptAt: 2, lastFailure: { kind: 'failed' } })] + rerender() + expect(result.current.commandRefusalCauses).toMatchObject({ sending: false, retry: true }) }) it('/clear behind only a failed message names its Retry, not the agent working', async () => { items = [] outboxEntries = [unsent('failed', { lastAttemptAt: 2, lastFailure: { kind: 'failed' } })] - const { result } = render() + const { result, rerender } = render() let outcome: unknown await act(async () => { outcome = await result.current.runConversationCommand('clear') @@ -584,9 +588,13 @@ describe('a /compact against a host that holds commands in line', () => { expect(outcome).toEqual({ accepted: false, error: 'Retry your earlier message, then run /clear.', - refusedWhile: 'outbox' + refusedWhile: 'retry' }) expect(commandCalls()).toHaveLength(0) + // Retried: on its way again, it offers no Retry, so that line goes. + outboxEntries = [unsent('failed')] + rerender() + expect(result.current.commandRefusalCauses).toMatchObject({ sending: true, retry: false }) }) it('/clear mid-turn is still refused here, and never asks to wait', async () => { diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 77b803cb030c..f2e02effccbb 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -43,12 +43,13 @@ export type StructuredAgentSessionComposerOptions = { } /** What the chat shows a refused command waiting on: the agent working, a pending prompt, its - * background tasks, or a message of this window's not yet gone. */ + * background tasks, a message of this window's still being sent, or one showing its Retry. */ export type StructuredAgentSessionCommandRefusalCause = | 'working' | 'prompt' | 'background' - | 'outbox' + | 'sending' + | 'retry' export type StructuredAgentSessionCommandOutcome = { handled: boolean From a967125c97b523ff6bc429d624c56003655b0476 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:56:35 -0700 Subject: [PATCH 23/27] test(native-chat): a Stop behind a typed /compact keeps the taken-back message on screen; the in-doubt case says so --- ...se-structured-agent-session.queued-gating.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 ba010ddaa12a..6a7b01002ebb 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 @@ -464,7 +464,7 @@ describe('a /compact against a host that holds commands in line', () => { expect(commandCalls()).toHaveLength(1) }) - it('a send stuck behind one in doubt never holds the command for good: Stop gives it back', async () => { + it('a send stuck behind one in doubt never holds the command for good: Stop takes it out of line', async () => { items = [] answerCommands({ command: 'compact', state: 'completed' }) // The first send's outcome is unknown and waits for its Retry; the second waits behind it. @@ -473,7 +473,8 @@ describe('a /compact against a host that holds commands in line', () => { lastAttemptAt: 2, retryAfterUnknownSubmittedAt: 2 }) - outboxEntries = [inDoubt, unsent('behind')] + const behind = unsent('behind') + outboxEntries = [inDoubt, behind] const { result, rerender } = render() expect(result.current.canStop).toBe(true) await act(async () => { @@ -483,13 +484,15 @@ describe('a /compact against a host that holds commands in line', () => { }) }) // The busy control is Stop, always pressable. Stop takes back what has not gone out - // (`withdrawUnsentStructuredAgentSessionOutboxEntries`), leaving only the one in doubt. + // (`withdrawUnsentStructuredAgentSessionOutboxEntries`), leaving only the one in doubt to send. expect( withdrawUnsentStructuredAgentSessionOutboxEntries(outboxEntries, [], null).map( (entry) => entry.clientMessageId ) ).toEqual(['in-doubt']) - outboxEntries = [inDoubt] + // The composer holds /compact, so the taken-back message stays on screen with its Retry + // (`useStructuredAgentSessionOutboxOwnership`), no longer on its way. + outboxEntries = [inDoubt, { ...behind, state: 'rejected' }] rerender() expect(result.current.canStop).toBe(false) await act(async () => { From fc7a25199a7a6cfa0f5cf50929443d27519e3444 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:26:43 -0700 Subject: [PATCH 24/27] fix(native-chat): a command refused while the queue is about to send reads working, as the pane does --- .../native-chat/structured-conversation-command-send.ts | 4 +++- .../use-structured-agent-session.queued-gating.test.tsx | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 5702d8f618ef..36e53cb0bd62 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 @@ -200,6 +200,8 @@ export function structuredConversationCommandRunner(args: { 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[] } @@ -217,7 +219,7 @@ export function structuredConversationCommandRunner(args: { commandRefusalCauses: StructuredConversationCommandCauses } { const promptPending = args.prompts.length > 0 - const agentWorking = args.chat.turnId !== null || args.chat.isWorking + const agentWorking = args.chat.turnId !== null || args.chat.isWorking || args.chat.queueSendsNext const rows = outboxRows(args.outbox, args.chat.submissions, args.agentName) const outboxUnsent = hasUnsentStructuredAgentSessionOutboxEntry( args.outbox, 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 5378bfb46d81..32cb371eecd1 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 @@ -371,10 +371,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', () => { From c79a2437e4efb96215123275214b47bde2474aad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:16:47 -0700 Subject: [PATCH 25/27] Update held command fixture for the merged queue model --- .../use-structured-agent-session.queued-gating.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 32cb371eecd1..50f565bf9f7d 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 @@ -704,7 +704,7 @@ describe('a /compact against a host that holds commands in line', () => { blocks: [{ type: 'text' as const, text: '/compact' }], command: { name: 'compact' as const } }, - ...(held ? { paused: true as const, pausedReason: 'kept' 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', () => { @@ -716,7 +716,7 @@ describe('a /compact against a host that holds commands in line', () => { }) }) - it('a kept command card, which the queue skips, does not force a send to queue', () => { + it('a send-failed command card, which the queue skips, does not force a send to queue', () => { queuedMessages = [compactCard(true)] render(false) expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ From b6cbc76c74b344c3defd1d55301c69dcf483b797 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:44:39 -0700 Subject: [PATCH 26/27] Restart one host at a time in the recovery ordering test --- .../structured-agent-session-queued-compact.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 77061b9481a8..a70941211a82 100644 --- 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 @@ -417,21 +417,23 @@ describe('a /compact that waits in line', () => { const kept = rig.send('kept message') await kept.result await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore)) - rig.crashRestartHostProcess() + // 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') - await next.result + 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)).toBeDefined()) + 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' } ]) From 62bde7881866da72608cb5f7aac1d7bc67554cea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:51:25 -0700 Subject: [PATCH 27/27] fix: preserve person queue reserve for chat commands --- ...tured-agent-session-queued-compact.test.ts | 40 ++++++++++++++++++- .../structured-conversation-command.ts | 2 + .../structured-conversation-compaction.ts | 8 ++-- .../rpc/methods/structured-agent-session.ts | 2 +- 4 files changed, 44 insertions(+), 8 deletions(-) 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 index a70941211a82..f619112a2f31 100644 --- 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 @@ -15,8 +15,13 @@ import { } 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 @@ -26,13 +31,18 @@ beforeEach(async () => { afterEach(() => rig.dispose()) -function compact(delivery?: 'queue-if-active', clientOperationId = hostTestOperationId()) { +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 + ...fields, + ...(options?.internal ? {} : { userSend: true as const }) }) } } @@ -88,6 +98,32 @@ const BACKGROUND_TASK: AgentChildWorkView = { 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() 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 dc503aafe4ba..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 @@ -41,6 +41,8 @@ export type ConversationCommandParams = { 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.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 3521dccb730d..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 @@ -10,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, @@ -133,7 +130,7 @@ function compactionReply( function acceptStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { envelope: AgentSessionMutationEnvelope; delivery?: 'queue-if-active' }, + params: ConversationCommandParams, priorRecord: () => AgentSessionConversationCommandResult | null ): Promise> { const plan = conversationCommandPlan({ @@ -153,6 +150,7 @@ function acceptStructuredConversationCommand( 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) { 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()