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 : (
+
+ >
)}
-
-
-
-
-
-
-
- {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()