diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json
index 72b78e9e5ab4..b44dcb2852a7 100644
--- a/config/tsconfig.node.json
+++ b/config/tsconfig.node.json
@@ -15,6 +15,7 @@
"../src/renderer/src/components/native-chat/structured-agent-question-projection.ts",
"../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts",
"../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts",
+ "../src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts",
"../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts",
"../src/preload/**/*",
"../src/shared/**/*",
diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
index 5eb53046128b..388cf0227880 100644
--- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
+++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
@@ -157,6 +157,48 @@ describe('MobileNativeChatQueuedMessages', () => {
expect(nodeTypes(rows[1]!)).toContain('CornerDownRight')
})
+ it('a command card never steers: Send only while the agent is idle, and no menu', async () => {
+ const onSend = vi.fn(async () => true)
+ const mounted = await mount({
+ cards: [
+ card({ messageId: 'working', text: '/compact', command: true, waitsForAgent: true }),
+ card({ messageId: 'idle', text: '/compact', command: true })
+ ],
+ onSend
+ })
+ expect(texts(mounted).filter((text) => text === 'Send' || text === 'Steer')).toEqual(['Send'])
+ await act(async () => {
+ mounted.root.findByProps({ accessibilityLabel: 'Send this message' }).props.onPress()
+ })
+ expect(onSend).toHaveBeenCalledWith('idle')
+ // Its menu holds only Edit, which a command does not take.
+ expect(mounted.root.findAllByProps({ accessibilityLabel: 'More actions' })).toHaveLength(0)
+ expect(
+ mounted.root.findAllByProps({ accessibilityLabel: 'Delete this queued message' })
+ ).toHaveLength(2)
+ })
+
+ it("a paused command card's ways out are Delete and the queue's Resume", async () => {
+ const onResume = vi.fn(async () => true)
+ const onDelete = vi.fn(async () => true)
+ const mounted = await mount({
+ cards: [card({ messageId: 'compact', text: '/compact', command: true })],
+ pause: { reason: 'stopped' },
+ onResume,
+ onDelete
+ })
+ await act(async () => {
+ mounted.root
+ .findByProps({ accessibilityLabel: 'Resume sending the queued messages' })
+ .props.onPress()
+ })
+ expect(onResume).toHaveBeenCalledOnce()
+ await act(async () => {
+ mounted.root.findByProps({ accessibilityLabel: 'Delete this queued message' }).props.onPress()
+ })
+ expect(onDelete).toHaveBeenCalledWith('compact')
+ })
+
it("holds Steer while a person's Stop ends the turn; Delete still works", async () => {
const onSend = vi.fn(async () => true)
const onDelete = vi.fn(async () => true)
diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx
index fa44fdd01fda..3aff7ff1bd80 100644
--- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx
+++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx
@@ -110,7 +110,7 @@ export function MobileNativeChatQueuedMessages({
const returned = card.state === 'returned'
// "Steer" submits beside the running turn, the paused queue's cards too; a card whose
// own send failed, or a returned one, is sent again.
- const steers = !returned && !card.paused
+ const steers = !returned && !card.paused && !card.command
return (
) : null}
- [
- styles.textAction,
- pressed && styles.pressed,
- (busy || steerHeld) && styles.disabled
- ]}
- disabled={busy || steerHeld}
- onPress={() => void run(card.messageId, onSend)}
- >
- {steers ? (
-
- ) : (
-
- )}
- {steers ? 'Steer' : 'Send'}
-
+ {/* A command never steers: its Send shows only while the agent is idle. */}
+ {card.waitsForAgent ? null : (
+ [
+ styles.textAction,
+ pressed && styles.pressed,
+ (busy || steerHeld) && styles.disabled
+ ]}
+ disabled={busy || steerHeld}
+ onPress={() => void run(card.messageId, onSend)}
+ >
+ {steers ? (
+
+ ) : (
+
+ )}
+ {steers ? 'Steer' : 'Send'}
+
+ )}
- [
- styles.iconAction,
- pressed && styles.pressed,
- busy && styles.disabled
- ]}
- disabled={busy}
- onPress={() => setMenuFor(card.messageId)}
- >
-
-
+ {/* The menu holds only Edit, which a command does not take. */}
+ {card.command ? null : (
+ [
+ styles.iconAction,
+ pressed && styles.pressed,
+ busy && styles.disabled
+ ]}
+ disabled={busy}
+ onPress={() => setMenuFor(card.messageId)}
+ >
+
+
+ )}
)
})}
diff --git a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx
index 41dc8b5d11f5..05decb191a55 100644
--- a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx
+++ b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx
@@ -44,6 +44,7 @@ vi.mock('./use-mobile-native-chat-send-error', () => ({
message: null,
show: () => {},
clear: () => {},
+ keepWhile: () => {},
bannerMountedRef: { current: false }
})
}))
diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts
index 74980499eb9c..56a742f33733 100644
--- a/mobile/src/session/mobile-native-chat-controller-contract.ts
+++ b/mobile/src/session/mobile-native-chat-controller-contract.ts
@@ -12,6 +12,7 @@ import type {
} from '../../../src/shared/native-chat-turn-status'
import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
+import type { MobileNativeChatCommandRefusalCauses } from './use-mobile-native-chat-send-error'
import type { MobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls'
import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
@@ -45,6 +46,8 @@ export type MobileNativeChatController = {
nativeChatSettledTurns: NativeChatSettledTurns | null
/** Structured lane: the journal that places each transcript row in its turn. */
nativeChatTurnJournal: NativeChatTurnJournal | null
+ /** What a refused command's line stands on. */
+ nativeChatCommandRefusalCauses: MobileNativeChatCommandRefusalCauses
nativeChatCanStop: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx
index f24470234903..5e933c94e49b 100644
--- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx
+++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx
@@ -88,6 +88,7 @@ const HOST_SUPPORT = {
promptCancel: true,
questionAnswers: true,
queuedMessages: true,
+ queuedCommands: false,
statusFeed: true,
quietRepeatedStop: true
}
diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts
new file mode 100644
index 000000000000..5c6ccec5ab12
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-contract.ts
@@ -0,0 +1,40 @@
+import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
+import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read'
+import type { MobileChatPermission } from './mobile-native-chat-permission'
+import type { MobileChatQuestion } from './mobile-native-chat-question'
+import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
+import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status'
+import type { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
+import type { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing'
+import type { StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome'
+import type { MobileNativeChatCommandRefusalCauses } from './use-mobile-native-chat-send-error'
+import type { MobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls'
+import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks'
+
+export type StructuredMobileSession = ReturnType &
+ ReturnType & {
+ session: MobileNativeChatSession
+ isWorking: boolean
+ turnId: string | null
+ /** What labels the live turn's one indicator row. */
+ turnIndicator: NativeChatLiveTurnIndicator
+ sendWithOutcome: (
+ text: string,
+ images?: string[],
+ deadline?: number,
+ attachments?: readonly StructuredMobileSendAttachment[]
+ ) => Promise
+ cancel: () => void
+ permission: MobileChatPermission | null
+ question: MobileChatQuestion | null
+ respondPermission: (optionId: string) => Promise
+ respondQuestion: (answer: string) => Promise
+ cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise
+ /** The queued-draft cards and their actions, from any host that publishes them. */
+ queued: MobileStructuredQueuedMessageControls
+ commandRefusalCauses: MobileNativeChatCommandRefusalCauses
+ /** Running child work for the strip above the composer, as desktop shows it. */
+ backgroundTasks: MobileStructuredBackgroundTasks
+ /** Where this chat's `::orca-visual` lines read their HTML from; null without a client. */
+ visualSource: MobileNativeChatVisualSource | null
+ }
diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
index b6e91fa8a7b4..e5debf14376a 100644
--- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
+++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
@@ -14,6 +15,7 @@ describe('structuredAgentSessionHostSupport', () => {
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
+ queuedCommands: false,
quietRepeatedStop: false,
statusFeed: false
}
@@ -27,6 +29,9 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY])
).toEqual({ ...none, queuedMessages: true })
+ expect(
+ structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY])
+ ).toEqual({ ...none, queuedCommands: true })
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY])
).toEqual({ ...none, quietRepeatedStop: true })
diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts
index cb5dcc5c8ec2..84c329e63738 100644
--- a/mobile/src/session/mobile-structured-agent-session-host-support.ts
+++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts
@@ -1,6 +1,7 @@
import {
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY
@@ -12,6 +13,8 @@ export type StructuredAgentSessionHostSupport = {
questionAnswers: boolean
/** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */
queuedMessages: boolean
+ /** A /compact sent while the agent works waits as a card; an older host refuses it. */
+ queuedCommands: boolean
/** The host publishes every session's status on one stream. A host from before phones could
* read it refuses the call, which reads the same as its absence. */
statusFeed: boolean
@@ -26,6 +29,7 @@ export function structuredAgentSessionHostSupport(
promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY),
questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY),
queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY),
+ queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY),
statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY)
}
diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts
index 8a54140a7dc3..4d62e05da9f2 100644
--- a/mobile/src/session/mobile-structured-composer-command.test.ts
+++ b/mobile/src/session/mobile-structured-composer-command.test.ts
@@ -32,7 +32,8 @@ function setup() {
setOption: vi.fn(async () => true),
conversationCommands: ['clear', 'compact']
},
- canRun: () => true,
+ busy: () => null,
+ waitsInLine: () => false,
onError: vi.fn(),
timeoutMs: 15000
}
@@ -102,7 +103,7 @@ describe('mobile structured conversation commands', () => {
input.text = '/compact instructions'
}
if (reason === 'pending work') {
- input.canRun = () => false
+ input.busy = () => 'working'
}
expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
expect(sendRequest).not.toHaveBeenCalled()
@@ -119,6 +120,84 @@ describe('mobile structured conversation commands', () => {
expect(fields.command).toBe('clear')
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
+ it('a /compact the host holds in line skips the busy check, asks to wait, and shows nothing', async () => {
+ const { input, sendRequest } = setup()
+ sendRequest.mockResolvedValueOnce({
+ ok: true,
+ result: {
+ ok: true,
+ value: {
+ command: 'compact',
+ state: 'completed',
+ queued: { messageId: 'op', position: 1, state: 'waiting' }
+ }
+ }
+ })
+ input.busy = () => 'working'
+ input.waitsInLine = (command) => command === 'compact'
+ expect(await dispatchMobileStructuredCommand(input)).toBe('accepted')
+ const fields = requestFields(sendRequest.mock.calls[0])
+ expect(fields).toMatchObject({ command: 'compact', delivery: 'queue-if-active' })
+ expect(input.onError).not.toHaveBeenCalled()
+ // A /clear never waits: the busy check still answers it.
+ expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected')
+ expect(sendRequest).toHaveBeenCalledOnce()
+ })
+ it('a /clear while the agent works says so in plain words', async () => {
+ const { input, sendRequest } = setup()
+ input.busy = () => 'working'
+ expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected')
+ expect(input.onError).toHaveBeenLastCalledWith(
+ "The agent is still working. Run /clear when it's done.",
+ 'working'
+ )
+ input.busy = () => 'prompt'
+ expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected')
+ expect(input.onError).toHaveBeenLastCalledWith(
+ "Answer the agent's question or approval, then run /clear.",
+ 'prompt'
+ )
+ input.busy = () => 'working'
+ expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
+ expect(input.onError).toHaveBeenLastCalledWith(
+ "The agent is still working. Run /compact when it's done.",
+ 'working'
+ )
+ expect(sendRequest).not.toHaveBeenCalled()
+ })
+ it("a host's refusal names its cause only when the phone showed it at the press", async () => {
+ const { input, sendRequest } = setup()
+ const refused = {
+ ok: true,
+ result: {
+ ok: true,
+ value: {
+ command: 'compact',
+ state: 'completed',
+ error: "The agent is still working. Run /compact when it's done.",
+ failure: {
+ kind: 'commandRefused',
+ refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } }
+ }
+ }
+ }
+ }
+ sendRequest.mockResolvedValue(refused)
+ input.waitsInLine = () => true
+ input.busy = () => 'working'
+ expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
+ expect(input.onError).toHaveBeenLastCalledWith(
+ "The agent is still working. Run /compact when it's done.",
+ 'working'
+ )
+ // Ahead of the phone: said as any failure, so it can't go before it is read.
+ input.busy = () => null
+ expect(await dispatchMobileStructuredCommand(input)).toBe('rejected')
+ expect(input.onError).toHaveBeenLastCalledWith(
+ "The agent is still working. Run /compact when it's done.",
+ undefined
+ )
+ })
it('keeps ordinary messages on the existing send path', async () => {
const { input, sendRequest } = setup()
expect(await dispatchMobileStructuredCommand({ ...input, text: 'hello' })).toBeNull()
diff --git a/mobile/src/session/mobile-structured-composer-command.ts b/mobile/src/session/mobile-structured-composer-command.ts
index ebe30fe88031..d8012a5555a7 100644
--- a/mobile/src/session/mobile-structured-composer-command.ts
+++ b/mobile/src/session/mobile-structured-composer-command.ts
@@ -1,13 +1,32 @@
-import type { AgentSessionConversationCommandResult } from '../../../src/shared/agent-session-conversation-command'
+import type {
+ AgentSessionConversationCommand,
+ AgentSessionConversationCommandResult
+} from '../../../src/shared/agent-session-conversation-command'
import {
dispatchStructuredAgentSessionComposerCommand,
isStructuredAgentSessionComposerCommand,
+ type StructuredAgentSessionCommandRefusalCause,
type StructuredAgentSessionComposerOptions
} from '../../../src/shared/structured-agent-session-composer'
+import { structuredAgentSessionCommandHostRefusalCause } from '../../../src/shared/structured-agent-session-command-refusal-cause'
+import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { requestStructuredAgentSessionMutation } from './mobile-structured-agent-session-rpc'
+/** What the person sees and can do, in the words the desktop uses. */
+function busyCommandText(
+ command: AgentSessionConversationCommand,
+ busy: 'working' | 'prompt'
+): string {
+ const clear = command === 'clear'
+ return agentSessionWriteNoticeEnglish(
+ busy === 'prompt'
+ ? [clear ? 'clearAfterAnswer' : 'compactAfterAnswer']
+ : ['agentStillWorking', clear ? 'runClearWhenDone' : 'runCompactWhenDone']
+ )
+}
+
export async function dispatchMobileStructuredCommand(input: {
text: string
hasAttachments: boolean
@@ -16,8 +35,12 @@ export async function dispatchMobileStructuredCommand(input: {
fence: number
pending: { current: boolean }
controller: StructuredAgentSessionComposerOptions
- canRun: () => boolean
- onError: (message: string) => void
+ /** What the agent still has in flight that refuses a command now; null when nothing does. */
+ busy: () => 'working' | 'prompt' | null
+ /** The host holds this command as a card behind work in flight, so nothing here holds it. */
+ waitsInLine: (command: AgentSessionConversationCommand) => boolean
+ /** `refusedWhile`: what the phone showed the refused command waiting on. */
+ onError: (message: string, refusedWhile?: StructuredAgentSessionCommandRefusalCause) => void
timeoutMs: number
}): Promise {
if (input.pending.current) {
@@ -34,11 +57,11 @@ export async function dispatchMobileStructuredCommand(input: {
const outcome = await dispatchStructuredAgentSessionComposerCommand(input.text, {
...input.controller,
runConversationCommand: async (command) => {
- if (!input.canRun()) {
- return {
- accepted: false,
- error: 'Wait for pending work to finish before using this command.'
- }
+ const shown = input.busy()
+ const waitsInLine = input.waitsInLine(command)
+ const busy = waitsInLine ? null : shown
+ if (busy) {
+ return { accepted: false, error: busyCommandText(command, busy), refusedWhile: busy }
}
input.pending.current = true
try {
@@ -49,7 +72,7 @@ export async function dispatchMobileStructuredCommand(input: {
expectedRuntimeFence: input.fence,
method: 'agentSession.conversationCommand',
fingerprintMethod: 'agentSession.conversationCommand',
- fields: { command },
+ fields: waitsInLine ? { command, delivery: 'queue-if-active' } : { command },
timeoutMs: Math.max(input.timeoutMs, 195_000)
})
if (
@@ -62,16 +85,25 @@ export async function dispatchMobileStructuredCommand(input: {
error: 'Conversation operation was not confirmed.'
}
}
- return result.status === 'accepted'
- ? { accepted: !result.value.error, error: result.value.error ?? null }
- : { accepted: false, error: result.message }
+ if (result.status !== 'accepted') {
+ return { accepted: false, error: result.message }
+ }
+ // A refusal names its cause only when the phone showed it, as on desktop.
+ const cause = structuredAgentSessionCommandHostRefusalCause(result.value)
+ return {
+ accepted: !result.value.error,
+ error: result.value.error ?? null,
+ ...(result.value.error && cause !== undefined && cause === shown
+ ? { refusedWhile: cause }
+ : {})
+ }
} finally {
input.pending.current = false
}
}
})
if (outcome.error) {
- input.onError(outcome.error)
+ input.onError(outcome.error, outcome.refusedWhile)
}
return unknown ? 'unknown' : outcome.accepted ? 'accepted' : 'rejected'
}
diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts
index 8ead70b20275..1c7d217368e7 100644
--- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts
+++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts
@@ -30,6 +30,62 @@ function draft(overrides: Partial & { messageId: stri
}
describe('mobileQueuedMessageCards', () => {
+ it('marks a /compact card as a command, its text as typed', () => {
+ const [card] = mobileQueuedMessageCards(
+ [
+ draft({
+ messageId: 'c',
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '/compact' }],
+ command: { name: 'compact' }
+ }
+ })
+ ],
+ [],
+ { pendingPrompt: false }
+ )
+ expect(card).toMatchObject({ text: '/compact', command: true, caption: null })
+ expect(card).not.toHaveProperty('waitsForAgent')
+ expect(
+ mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { pendingPrompt: false })[0]
+ ).not.toHaveProperty('command')
+ const compact = draft({
+ messageId: 'c',
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '/compact' }],
+ command: { name: 'compact' }
+ }
+ })
+ expect(
+ mobileQueuedMessageCards([compact], [], { pendingPrompt: false, agentWorking: true })[0]
+ ).toMatchObject({ command: true, waitsForAgent: true })
+ })
+
+ it("a send-failed command card's caption names Send only when Send is there", () => {
+ const failed = draft({
+ messageId: 'c',
+ paused: true,
+ pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED,
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '/compact' }],
+ command: { name: 'compact' }
+ }
+ })
+ expect(
+ mobileQueuedMessageCards([failed], [], { pendingPrompt: false, agentWorking: true })[0]
+ ?.caption
+ ).toBe("Couldn't send — tap Send to retry once the agent finishes")
+ expect(mobileQueuedMessageCards([failed], [], { pendingPrompt: false })[0]?.caption).toBe(
+ "Couldn't send — tap Send to retry"
+ )
+ })
+
it('renders nothing without a published list', () => {
expect(mobileQueuedMessageCards(null, [], { pendingPrompt: false })).toEqual([])
expect(mobileQueuedMessageCards([], [], { pendingPrompt: false })).toEqual([])
diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts
index d65a73388c3d..46d019f132be 100644
--- a/mobile/src/session/mobile-structured-queued-message-cards.ts
+++ b/mobile/src/session/mobile-structured-queued-message-cards.ts
@@ -27,6 +27,10 @@ export type MobileQueuedMessageCard = {
caption: string | null
/** "From " on another agent's card; null on the person's. */
attribution: string | null
+ /** A conversation command such as /compact: it never steers into a running turn. */
+ command?: true
+ /** A command card while the agent works: it offers no send until the agent is idle. */
+ waitsForAgent?: true
}
function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string {
@@ -53,9 +57,11 @@ function returnedCaption(
}
/** One card's own hold: a failed conversion; the queue's pause is the list's first row. */
-function pausedCaption(reason: string | undefined): string {
+function pausedCaption(reason: string | undefined, waitsForAgent: boolean): string {
if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) {
- return "Couldn't send — tap Send to retry"
+ return waitsForAgent
+ ? "Couldn't send — tap Send to retry once the agent finishes"
+ : "Couldn't send — tap Send to retry"
}
// Absent or unknown (newer host) marker: a plain pause, promising no release rule.
return 'Paused'
@@ -93,7 +99,7 @@ export function mobileQueuePauseLabel(pause: Pick[],
- facts: { pendingPrompt: boolean; queuePaused?: boolean }
+ facts: { pendingPrompt: boolean; queuePaused?: boolean; agentWorking?: boolean }
): MobileQueuedMessageCard[] {
if (!queuedMessages || queuedMessages.length === 0) {
return []
@@ -116,7 +122,10 @@ export function mobileQueuedMessageCards(
draft.state === 'returned'
? returnedCaption(draft)
: paused
- ? pausedCaption(draft.pausedReason)
+ ? pausedCaption(
+ draft.pausedReason,
+ draft.body.command !== undefined && facts.agentWorking === true
+ )
: behindReturned
? 'Waiting — a message ahead needs attention'
: facts.queuePaused
@@ -134,7 +143,13 @@ export function mobileQueuedMessageCards(
draft.state === 'returned' ||
(paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED),
caption,
- attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from))
+ attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from)),
+ ...(draft.body.command !== undefined
+ ? {
+ command: true as const,
+ ...(facts.agentWorking ? { waitsForAgent: true as const } : {})
+ }
+ : {})
})
if (draft.state === 'returned') {
behindReturned = true
diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts
index 7e0c29ecb1ea..9a7328c8450c 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.ts
@@ -40,7 +40,8 @@ export function useMobileNativeChatController(args: {
connState: ConnectionState
/** Host capability fact from the shared runtime status probe. */
agentSessionHostSupport?: StructuredAgentSessionHostSupport | null
- onSendError: (message: string) => void
+ /** Forwarded to the session lane, a refusal's cause with it. */
+ onSendError: Parameters[0]['onSendError']
/** Retires a held failure banner. Any accepted chat write clears it — a delivered
* answer or permission reply must not sit under a stale "not sent". */
onSendResolved: () => void
@@ -300,6 +301,8 @@ export function useMobileNativeChatController(args: {
nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null,
nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null,
nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null,
+ // Only the structured lane names a refusal's cause; the bridge lane's starved ones drop none.
+ nativeChatCommandRefusalCauses: structuredNativeChat.commandRefusalCauses,
nativeChatCanStop: activeChatStructured
? structuredNativeChat.turnId !== null
: nativeChatAgentWorking,
diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts
index 49ca07198d03..c105a0b54373 100644
--- a/mobile/src/session/use-mobile-native-chat-send-error.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts
@@ -1,7 +1,10 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { useMobileNativeChatSendError } from './use-mobile-native-chat-send-error'
+import {
+ useMobileNativeChatSendError,
+ type MobileNativeChatCommandRefusalCauses
+} from './use-mobile-native-chat-send-error'
type HookApi = ReturnType
@@ -13,13 +16,17 @@ describe('useMobileNativeChatSendError', () => {
function Harness({
scopeKey,
- bannerMounted = true
+ bannerMounted = true,
+ causes = null
}: {
scopeKey: string | null
bannerMounted?: boolean
+ causes?: MobileNativeChatCommandRefusalCauses | null
}): null {
const api = useMobileNativeChatSendError({ scopeKey, showToast })
api.bannerMountedRef.current = bannerMounted
+ // As the route does each render, with what the chat shows.
+ api.keepWhile(causes)
apiRef.current = api
return null
}
@@ -49,6 +56,48 @@ describe('useMobileNativeChatSendError', () => {
vi.useRealTimers()
})
+ async function showing(causes: MobileNativeChatCommandRefusalCauses): Promise {
+ await act(async () => {
+ renderer?.update(createElement(Harness, { scopeKey: 'terminal-1', causes }))
+ })
+ }
+
+ it('a /clear refused while the agent works goes when the agent stops, and stays gone', async () => {
+ const line = "The agent is still working. Run /clear when it's done."
+ await render()
+ await showing({ working: true, prompt: false })
+ await act(async () => api().show(line, 'working'))
+ expect(api().message).toBe(line)
+ await showing({ working: true, prompt: false })
+ expect(api().message).toBe(line)
+
+ await showing({ working: false, prompt: false })
+ expect(api().message).toBeNull()
+ // Dropped, not hidden: the agent working again is not what that press was refused for.
+ await showing({ working: true, prompt: false })
+ expect(api().message).toBeNull()
+ })
+
+ it('a refusal behind a question goes once it is answered', async () => {
+ await render()
+ await showing({ working: false, prompt: true })
+ await act(async () =>
+ api().show("Answer the agent's question or approval, then run /clear.", 'prompt')
+ )
+ await showing({ working: false, prompt: false })
+ expect(api().message).toBeNull()
+ })
+
+ it('a failure naming nothing the phone shows, or background tasks it cannot see, stays', async () => {
+ await render()
+ await act(async () => api().show('Message not sent'))
+ await showing({ working: false, prompt: false })
+ expect(api().message).toBe('Message not sent')
+ await act(async () => api().show('Background tasks are still running.', 'background'))
+ await showing({ working: false, prompt: false })
+ expect(api().message).toBe('Background tasks are still running.')
+ })
+
it('holds a failure for four seconds, then drops it', async () => {
await render()
await act(async () => api().show('a'))
diff --git a/mobile/src/session/use-mobile-native-chat-send-error.ts b/mobile/src/session/use-mobile-native-chat-send-error.ts
index 359b94a8336e..d0b7b0427307 100644
--- a/mobile/src/session/use-mobile-native-chat-send-error.ts
+++ b/mobile/src/session/use-mobile-native-chat-send-error.ts
@@ -1,4 +1,16 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
+import type { StructuredAgentSessionCommandRefusalCause } from '../../../src/shared/structured-agent-session-composer'
+
+/** `refusedWhile`: a refused command's cause, which its line is said only while the chat shows. */
+export type MobileNativeChatSendErrorReporter = (
+ message: string,
+ refusedWhile?: StructuredAgentSessionCommandRefusalCause
+) => void
+
+/** What the phone's chat shows a refused command waiting on. */
+export type MobileNativeChatCommandRefusalCauses = Readonly<
+ Partial>
+>
const NATIVE_CHAT_SEND_ERROR_HOLD_MS = 4000
const NATIVE_CHAT_SEND_ERROR_TOAST_MS = 1600
@@ -13,12 +25,17 @@ export function useMobileNativeChatSendError(args: {
showToast: (message: string, durationMs?: number) => void
}): {
message: string | null
- show: (message: string) => void
+ show: MobileNativeChatSendErrorReporter
clear: () => void
+ /** Called each render with what the chat shows: a refusal whose cause has ended is dropped. */
+ keepWhile: (causes: MobileNativeChatCommandRefusalCauses | null) => void
/** Set by the route each render; gates banner vs toast. */
bannerMountedRef: MutableRefObject
} {
const [message, setMessage] = useState(null)
+ const [refusedWhile, setRefusedWhile] = useState<
+ StructuredAgentSessionCommandRefusalCause | undefined
+ >(undefined)
const timerRef = useRef | null>(null)
const bannerMountedRef = useRef(false)
const showToastRef = useRef(args.showToast)
@@ -44,7 +61,7 @@ export function useMobileNativeChatSendError(args: {
setMessage(null)
}, [clearTimer, scopeKey])
const show = useCallback(
- (next: string) => {
+ (next: string, cause?: StructuredAgentSessionCommandRefusalCause) => {
// Why: deferred failures can land after the user left chat (banner unmounted)
// or moved to another tab, where the banner belongs to a different terminal —
// both must fall back to the toast instead of being swallowed or misattributed.
@@ -54,6 +71,7 @@ export function useMobileNativeChatSendError(args: {
}
clearTimer()
setMessage(next)
+ setRefusedWhile(cause)
timerRef.current = setTimeout(() => {
timerRef.current = null
setMessage(null)
@@ -76,5 +94,12 @@ export function useMobileNativeChatSendError(args: {
},
[clearTimer]
)
- return { message, show, clear, bannerMountedRef }
+ const keepWhile = (causes: MobileNativeChatCommandRefusalCauses | null) => {
+ // Dropped, not hidden: the cause coming back later is not what this refusal was about.
+ if (message !== null && refusedWhile !== undefined && causes?.[refusedWhile] === false) {
+ setMessage(null)
+ setRefusedWhile(undefined)
+ }
+ }
+ return { message, show, clear, keepWhile, bannerMountedRef }
}
diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts
index 15f68f0758b3..314c95a71092 100644
--- a/mobile/src/session/use-mobile-native-chat-session-lane.ts
+++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts
@@ -4,6 +4,7 @@ import type { ConnectionState } from '../transport/types'
import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
+import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error'
/** Mounts both transcript sources and hands back the one this tab's lane owns.
* Both hooks always run (hook order is fixed); the inactive lane is starved of
@@ -38,7 +39,7 @@ export function useMobileNativeChatSessionLane({
appendComposerTextRef: { readonly current: (text: string) => boolean }
enabled: boolean
connState: ConnectionState
- onSendError: (message: string) => void
+ onSendError: MobileNativeChatSendErrorReporter
/** Called on any accepted queued-card action; retires the route's failure banner. */
onActionResolved?: () => void
}): {
diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts
index 4dc1743e2219..075ee17c19ef 100644
--- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts
+++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts
@@ -79,6 +79,7 @@ export function useMobileSessionNativeChatDictation(
})
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
nativeChatSendError.bannerMountedRef.current = showNativeChat
+ nativeChatSendError.keepWhile(nativeChatController.nativeChatCommandRefusalCauses)
const nativeChatOverlayInputLockReason =
activeSessionTab?.type === 'agent-session'
? connState === 'connected'
diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
index 328a1effdd30..7a14895fd66e 100644
--- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
+++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
@@ -122,6 +122,7 @@ function Harness({
promptCancel: promptCancelSupported,
questionAnswers: questionAnswersSupported,
queuedMessages: false,
+ queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false
},
diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
index 163cb20246f4..7a0c57b9b352 100644
--- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
+++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
@@ -16,6 +16,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = {
promptCancel: false,
questionAnswers: false,
queuedMessages: true,
+ queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false
}
diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts
index b5f17dbbfa49..6ad65cf993b1 100644
--- a/mobile/src/session/use-mobile-structured-agent-session.ts
+++ b/mobile/src/session/use-mobile-structured-agent-session.ts
@@ -1,6 +1,5 @@
import { useCallback, useMemo, useRef } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
-import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
@@ -17,12 +16,9 @@ import {
projectStructuredPermission,
projectStructuredQuestion
} from './mobile-structured-agent-prompts'
-import type { RpcClient } from '../transport/rpc-client'
+import type { StructuredMobileSession } from './mobile-structured-agent-session-contract'
import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read'
-import type { MobileChatPermission } from './mobile-native-chat-permission'
-import type { MobileChatQuestion } from './mobile-native-chat-question'
-import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
-import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status'
+import type { RpcClient } from '../transport/rpc-client'
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press'
import { useMobileStructuredSessionHostStopping } from './use-mobile-structured-session-host-stopping'
@@ -38,44 +34,11 @@ import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mu
import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status'
import {
mobileStructuredSendQueues,
- useMobileStructuredSendWithOutcome,
- type StructuredMobileSendAttachment
+ useMobileStructuredSendWithOutcome
} from './use-mobile-structured-send-with-outcome'
-import {
- useMobileStructuredQueuedMessageControls,
- type MobileStructuredQueuedMessageControls
-} from './use-mobile-structured-queued-message-controls'
-import {
- useMobileStructuredBackgroundTasks,
- type MobileStructuredBackgroundTasks
-} from './use-mobile-structured-background-tasks'
-
-type StructuredMobileSession = ReturnType &
- ReturnType & {
- session: MobileNativeChatSession
- isWorking: boolean
- turnId: string | null
- /** What labels the live turn's one indicator row. */
- turnIndicator: NativeChatLiveTurnIndicator
- sendWithOutcome: (
- text: string,
- images?: string[],
- deadline?: number,
- attachments?: readonly StructuredMobileSendAttachment[]
- ) => Promise
- cancel: () => void
- permission: MobileChatPermission | null
- question: MobileChatQuestion | null
- respondPermission: (optionId: string) => Promise
- respondQuestion: (answer: string) => Promise
- cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise
- /** The queued-draft cards and their actions, from any host that publishes them. */
- queued: MobileStructuredQueuedMessageControls
- /** Running child work for the strip above the composer, as desktop shows it. */
- backgroundTasks: MobileStructuredBackgroundTasks
- /** Where this chat's `::orca-visual` lines read their HTML from; null without a client. */
- visualSource: MobileNativeChatVisualSource | null
- }
+import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error'
+import { useMobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls'
+import { useMobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks'
export function useMobileStructuredAgentSession(args: {
client: RpcClient | null
@@ -90,7 +53,7 @@ export function useMobileStructuredAgentSession(args: {
agent: string | null
/** The active pane's live composer; Edit copies a card's text through it. */
appendComposerText?: (text: string) => boolean
- onSendError: (message: string) => void
+ onSendError: MobileNativeChatSendErrorReporter
/** Called on any accepted queued-card action; retires the route's failure banner. */
onActionResolved?: () => void
}): StructuredMobileSession {
@@ -108,6 +71,8 @@ export function useMobileStructuredAgentSession(args: {
} = args
// Only a host that queues sends gets the delivery field; any host's published cards show.
const queueCapable = hostSupport?.queuedMessages === true
+ // A /compact waits in line only where its card renders.
+ const commandsWait = queueCapable && hostSupport?.queuedCommands === true
const promptCancelSupported = hostSupport?.promptCancel ?? null
const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null
const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId])
@@ -153,6 +118,7 @@ export function useMobileStructuredAgentSession(args: {
sessionId,
enabled,
queueCapable,
+ commandsWait,
stateRef,
commandPending: commandPendingRef,
controller: sendController,
@@ -238,12 +204,21 @@ export function useMobileStructuredAgentSession(args: {
() => state.items.find(pendingStructuredQuestion) ?? null,
[state.items]
)
+ // What a refused command's line on the phone stands on.
+ const commandRefusalCauses = useMemo(
+ () => ({
+ working: turnId !== null,
+ prompt: approvalPrompt !== null || questionPrompt !== null
+ }),
+ [approvalPrompt, questionPrompt, turnId]
+ )
const queued = useMobileStructuredQueuedMessageControls({
sessionKey,
queuedMessages,
queuePause,
submissions: state.submissions,
pendingPrompt: approvalPrompt !== null || questionPrompt !== null,
+ agentWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence),
mutate,
appendComposerText,
onSendError,
@@ -308,6 +283,7 @@ export function useMobileStructuredAgentSession(args: {
respondPermission,
respondQuestion,
queued,
+ commandRefusalCauses,
backgroundTasks
}
}
diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts
index a786eeacbe09..74518947e9bd 100644
--- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts
+++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts
@@ -50,6 +50,8 @@ export function useMobileStructuredQueuedMessageControls(args: {
queuePause: MobileQueuePause
submissions: readonly AgentJournalSubmission[]
pendingPrompt: boolean
+ /** The chat shows the agent working: a command card offers no send then. */
+ agentWorking?: boolean
mutate: MobileStructuredAgentMutate
/** The active pane's live composer, Edit's copy target; absent = Edit refuses. False when
* nothing was copied. */
@@ -69,13 +71,15 @@ export function useMobileStructuredQueuedMessageControls(args: {
sessionKey,
submissions
} = args
+ const agentWorking = args.agentWorking === true
const cards = useMemo(
() =>
mobileQueuedMessageCards(queuedMessages, submissions, {
pendingPrompt,
+ agentWorking,
queuePaused: queuePause !== null
}),
- [pendingPrompt, queuePause, queuedMessages, submissions]
+ [agentWorking, pendingPrompt, queuePause, queuedMessages, submissions]
)
const resolved = useCallback(
(accepted: boolean): boolean => {
@@ -135,7 +139,8 @@ export function useMobileStructuredQueuedMessageControls(args: {
// leaves the card beside the copy, visibly, never a silent duplicate. No
// copy (no composer yet, or an empty card) means no Delete: Edit never
// removes text it did not keep.
- if (!card || !appendComposerText?.(card.text)) {
+ // A command's text is not a draft: edited, it would become a message.
+ if (!card || card.command || !appendComposerText?.(card.text)) {
return false
}
onCopied?.()
diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts
index 4a7b1e67470c..c6c8e9604e07 100644
--- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts
+++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts
@@ -15,6 +15,7 @@ import type { StructuredAgentSessionState } from '../../../src/shared/structured
import type { RpcClient } from '../transport/rpc-client'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command'
+import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error'
import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send'
import { timeoutForDeadline } from './mobile-structured-agent-session-rpc'
import {
@@ -41,13 +42,15 @@ export function useMobileStructuredSendWithOutcome(args: {
sessionId: string | null
enabled: boolean
queueCapable: boolean
+ /** The host holds a /compact sent while the agent works as a card. */
+ commandsWait: boolean
stateRef: { readonly current: StructuredAgentSessionState }
commandPending: { current: boolean }
controller: Pick<
StructuredAgentSessionComposerOptions,
'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands'
>
- onSendError: (message: string) => void
+ onSendError: MobileNativeChatSendErrorReporter
}): (
text: string,
images?: string[],
@@ -58,6 +61,7 @@ export function useMobileStructuredSendWithOutcome(args: {
agent,
client,
commandPending,
+ commandsWait,
controller,
enabled,
onSendError,
@@ -98,11 +102,19 @@ export function useMobileStructuredSendWithOutcome(args: {
agent: agent === 'claude' ? 'claude' : 'codex',
...controller
},
- canRun: () =>
- !runningStructuredAgentSessionTurnId(stateRef.current) &&
- !stateRef.current.items.some(
+ busy: () =>
+ stateRef.current.items.some(
(item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item)
- ),
+ )
+ ? 'prompt'
+ : runningStructuredAgentSessionTurnId(stateRef.current)
+ ? 'working'
+ : null,
+ // A card waiting on a prompt nothing here can answer would hold it forever.
+ waitsInLine: (command) =>
+ command === 'compact' &&
+ commandsWait &&
+ !pendingPromptsAllUnanswerableHere(stateRef.current.items),
onError: onSendError,
timeoutMs
})
@@ -130,6 +142,7 @@ export function useMobileStructuredSendWithOutcome(args: {
agent,
client,
commandPending,
+ commandsWait,
controller,
enabled,
onSendError,
diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx
index c2cdf5d5114b..79f761f5511e 100644
--- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx
+++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx
@@ -265,6 +265,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => {
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
+ queuedCommands: false,
quietRepeatedStop: false,
statusFeed
},
diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts
index c56e4f47d45d..28bfdb3358b9 100644
--- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts
+++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts
@@ -33,10 +33,8 @@ export function consumedSubmissionWasRejected(
export type RejectedDraftSettlement = { state: 'returned' | 'waiting' }
/**
- * Where no one failed the user, the draft goes back to waiting, under whatever pauses the queue: a
- * Stop's, or, when a restart or a close cut it short, the reopen's, so it follows the chat's next
- * turn like every card the chat closed with. A returned card would block the drafts behind it on a
- * failure that never happened. A failure returns the card with its refusal for the user to act on.
+ * A withdrawal waits at its original position under the queue's pause; a failure returns the
+ * card, except a command refused in its own turn, whose row already reports it.
*/
export function rejectedDraftSettlement(
rejected: Pick & { rejection?: unknown }
diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts
index 9c30bf4311dd..dffe87f3af09 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts
@@ -11,6 +11,8 @@ import {
journalDispatchRowNewlyRejects
} from './journal-dispatch-settlement'
import type { JournalReducerState } from './journal-reducer'
+import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
+import { structuredAgentSessionCommandTurnIdentity } from '../../../shared/structured-agent-session-command-turn-identity'
import type { JournalRow } from './journal-row-schema'
import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo'
import {
@@ -58,6 +60,7 @@ export function settleOwedQueuedMessages(
consumedRef,
reason: submission?.reason ?? null,
rejection: submission?.rejection,
+ commandTurnReported: commandTurnReported(input.state, consumedRef),
now: input.now
})
settled += changed ? 1 : 0
@@ -123,7 +126,15 @@ export function settleQueuedMessagesForRow(
consumedRef: row.clientMessageId,
reason: row.reason,
rejection: row.rejection,
+ commandTurnReported: commandTurnReported(input.state, row.clientMessageId),
now: input.now
})
return changed + (settled ? 1 : 0)
}
+
+/** The submission's command turn is in the journal: its own row reports the refusal. */
+function commandTurnReported(state: JournalReducerState, clientMessageId: string): boolean {
+ return state.items.has(
+ agentJournalItemKey(structuredAgentSessionCommandTurnIdentity(clientMessageId))
+ )
+}
diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts
index 852ebc5d7ddf..aaaef6d4fa65 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-table.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts
@@ -5,7 +5,8 @@
// submission's append. Until then it lives here, `session_id`-keyed so it
// survives epoch rollover and replacement (`journal-row-table.ts` deletes only
// `journal_rows`). After a refusal its text survives as a `returned` row a
-// rewind cannot delete; after a withdrawal it waits again.
+// rewind cannot delete — a command refused in its own turn is spent instead, its
+// turn's row saying why; after a withdrawal it waits again.
import type Database from '../../sqlite/sync-database'
import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure'
@@ -226,8 +227,7 @@ export function withdrawQueuedMessages(
* dispatched → returned, or back to waiting (`rejectedDraftSettlement`),
* matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused
* again still settles while a late duplicate of an earlier refusal matches
- * nothing. A draft back to waiting keeps its position and carries no refusal; its spent
- * submissions stay findable by their `queuedMessageId` link.
+ * nothing. A withdrawal keeps its position; a command refused in its own turn is spent.
*/
export function settleRejectedQueuedMessage(
db: Database.Database,
@@ -236,10 +236,19 @@ export function settleRejectedQueuedMessage(
consumedRef: string
reason: string | null
rejection: UnreadAgentSessionFailureFact | undefined
+ /** The refused submission's command turn exists, so its own row says why. */
+ commandTurnReported: boolean
now: number
}
): boolean {
const settlement = rejectedDraftSettlement(input)
+ if (
+ settlement.state === 'returned' &&
+ input.commandTurnReported &&
+ spendRefusedCommandCard(db, input)
+ ) {
+ return true
+ }
const changed =
settlement.state === 'waiting'
? db
@@ -266,6 +275,26 @@ export function settleRejectedQueuedMessage(
return Number(changed.changes ?? 0) > 0
}
+/**
+ * A command card refused in its own turn is spent, not returned: that turn's row says why, once,
+ * and a returned card would hold every card behind it. Refused before any turn (a failed start),
+ * it is returned like any card; a restart or a close leaves it waiting under the queue's pause.
+ * False when the dispatched card is not a command.
+ */
+function spendRefusedCommandCard(
+ db: Database.Database,
+ input: { sessionId: string; consumedRef: string; now: number }
+): boolean {
+ const spent = db
+ .prepare(
+ `UPDATE queued_messages SET state = 'withdrawn', settled_at = ?
+ WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?
+ AND json_extract(body_json, '$.command') IS NOT NULL`
+ )
+ .run(input.now, input.sessionId, input.consumedRef)
+ return Number(spent.changes ?? 0) > 0
+}
+
/** Replay receipts: every row a given caller-scoped operation settled. */
export function queuedMessagesSettledByOp(
db: Database.Database,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts
index 5a1bbeab5120..30810b9b9f3e 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts
@@ -17,7 +17,6 @@ import {
type AgentSessionFailureWordsContext
} from '../../../shared/agent-session-failure-words'
import {
- agentJournalItemKey,
agentJournalSubmissionKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
@@ -43,6 +42,10 @@ import type {
} from './structured-agent-session-adapter'
import type { StructuredAgentRegistry } from './structured-agent-registry'
import { conversationCommandBlocked } from './structured-conversation-command-admission'
+import { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity'
+import { refuseQueuedCommand } from './structured-agent-session-queued-command-refusal'
+
+export { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity'
export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact'
@@ -87,24 +90,6 @@ export function structuredAgentSessionAwaitedCommand(
: undefined
}
-/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the
- * host's Stop and delivery gate tell a command's turn from any other. */
-export function structuredAgentSessionCommandTurn(clientMessageId: string): {
- identity: AgentJournalItemIdentity
- itemId: string
- turnId: string
- /** The command's one result row, inside its turn. */
- resultIdentity: AgentJournalItemIdentity
-} {
- const identity = { provider: 'orca' as const, clientMessageId: `command-turn:${clientMessageId}` }
- return {
- identity,
- itemId: agentJournalItemKey(identity),
- turnId: `compact:${clientMessageId}`,
- resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` }
- }
-}
-
/** Whether the journal's running turn is a command's, which takes no input while it runs. */
export function structuredAgentSessionCommandRunning(
journal: Pick
@@ -171,34 +156,30 @@ export async function handOverStructuredAgentSessionCommand(
// Provider frames already received decide whether a turn is running: each landed at its call.
const blocked = commandBlocked(ctx, body)
if (blocked) {
- await ctx.journal.resolveDispatch({
- clientMessageId,
- state: 'rejected',
- ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }),
- fence: ctx.fence
- })
+ const refused = {
+ state: 'rejected' as const,
+ ...agentSessionFailureWords(blocked, {
+ ...ctx.failureTextContext,
+ command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND,
+ surface: 'rejection'
+ })
+ }
+ if (submission.queuedMessageId === undefined) {
+ // The command RPC that sent it is still waiting, and its answer says why.
+ await ctx.journal.resolveDispatch({ clientMessageId, ...refused, fence: ctx.fence })
+ return
+ }
+ // A queued card's refusal has nobody waiting on it: its turn's one row says why.
+ await refuseQueuedCommand(
+ ctx,
+ submission,
+ blocked,
+ refused,
+ STRUCTURED_AGENT_SESSION_COMPACT_COMMAND
+ )
return
}
- const turn = structuredAgentSessionCommandTurn(clientMessageId)
- await ctx.journal.resolveDispatch({
- clientMessageId,
- state: 'pending',
- fence: ctx.fence,
- turnScope: ctx.journal.liveTurnScope()
- })
- const startedAt = ctx.now()
- const running = agentJournalTurnBody({
- turnId: turn.turnId,
- state: 'running',
- userItemId: agentJournalSubmissionKey(clientMessageId),
- requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission),
- startedAt
- })
- await ctx.journal.appendItem(turn.identity, running, {
- fence: ctx.fence,
- observedAt: startedAt,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
+ const { turn, running } = await openCommandTurn(ctx, submission)
let admission: AgentSessionCommandAdmission
try {
admission = await ctx.adapter.compact!({
@@ -228,6 +209,35 @@ export async function handOverStructuredAgentSessionCommand(
}
}
+/** Hands the command over and opens its own turn. */
+async function openCommandTurn(
+ ctx: StructuredAgentSessionCommandHandoverContext,
+ submission: AgentJournalSubmission
+) {
+ const { clientMessageId } = submission
+ const turn = structuredAgentSessionCommandTurn(clientMessageId)
+ await ctx.journal.resolveDispatch({
+ clientMessageId,
+ state: 'pending',
+ fence: ctx.fence,
+ turnScope: ctx.journal.liveTurnScope()
+ })
+ const startedAt = ctx.now()
+ const running = agentJournalTurnBody({
+ turnId: turn.turnId,
+ state: 'running',
+ userItemId: agentJournalSubmissionKey(clientMessageId),
+ requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission),
+ startedAt
+ })
+ await ctx.journal.appendItem(turn.identity, running, {
+ fence: ctx.fence,
+ observedAt: startedAt,
+ turnScope: AGENT_JOURNAL_THREAD_SCOPE
+ })
+ return { turn, running }
+}
+
/** Where the turn a handed-over submission runs in starts counting: its handover, so time spent
* held behind a command or a start is not counted as the agent's work. */
export function structuredAgentSessionHandoverOrigin(
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
index 4f30c3f1f00d..f9297723804f 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
@@ -20,6 +20,7 @@ import type {
AgentSessionMutationEnvelope,
AgentSessionOptionResult,
AgentSessionPromptResult,
+ AgentSessionQueuedSendReceipt,
AgentSessionSendResult
} from '../../../shared/agent-session-wire'
import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command'
@@ -167,13 +168,17 @@ export function sendPlan(params: {
export type ConversationCommandAcceptance =
| { clientMessageId: string }
+ /** Held as a card, keyed by the operation id, behind work in flight. */
+ | { queued: AgentSessionQueuedSendReceipt }
/** What an older build's run of this operation recorded. */
| { recorded: AgentSessionConversationCommandResult }
/** `/compact` accepted like a send: one submission, keyed by the operation id, that the delivery
- * loop carries out as the command's own turn. */
+ * loop carries out as the command's own turn — or, asked with `delivery`, a card while the
+ * agent works, which the queue's drain turns into that submission. */
export function conversationCommandPlan(params: {
envelope: AgentSessionMutationEnvelope
+ delivery?: 'queue-if-active'
priorRecord: () => AgentSessionConversationCommandResult | null
}): MutationPlan {
const clientMessageId = params.envelope.clientOperationId
@@ -181,7 +186,10 @@ export function conversationCommandPlan(params: {
method: 'agentSession.conversationCommand',
conversationWrite: true,
settlesWithWrite: true,
- fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND },
+ fields: {
+ command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND,
+ ...(params.delivery ? { delivery: params.delivery } : {})
+ },
recoverUnknownFromDurableState: true,
run: async (ctx) => {
const sent = await performSend(ctx, {
@@ -198,6 +206,17 @@ export function conversationCommandPlan(params: {
if (outcome.status === 'succeeded' && outcome.conversationCommand) {
return { recorded: outcome.conversationCommand }
}
+ // A card answers from itself until drained, then from the submission it became; only a
+ // command that asked to wait can have one, as for a send.
+ const queued =
+ params.delivery === 'queue-if-active'
+ ? queuedSendAnswer(ctx.journal, clientMessageId)
+ : null
+ if (queued) {
+ return 'queued' in queued
+ ? { queued: queued.queued }
+ : { clientMessageId: queued.submission.clientMessageId }
+ }
if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) {
return { clientMessageId }
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command-refusal.ts
new file mode 100644
index 000000000000..3546c0d9c2be
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command-refusal.ts
@@ -0,0 +1,69 @@
+// A queued command card's refusal at hand-over: nobody waits on it, so its own turn says why.
+
+import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure'
+import {
+ agentSessionFailureWords,
+ type AgentJournalDispatchRejection
+} from '../../../shared/agent-session-failure-words'
+import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
+import {
+ AGENT_JOURNAL_THREAD_SCOPE,
+ type AgentJournalSubmission
+} from '../../../shared/agent-session-journal-types'
+import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command'
+import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record'
+import { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity'
+import type { StructuredAgentSessionCommandHandoverContext } from './structured-agent-session-command-turn'
+
+/**
+ * A queued command refused before it was handed over. Nothing reached the provider, so no hand-off
+ * is recorded: the ended turn and its row land in one batch, then the submission is rejected while
+ * still queued. A crash between leaves a queued submission, which a restart returns to waiting;
+ * never a card gone with no row, nor a command that may have run.
+ */
+export async function refuseQueuedCommand(
+ ctx: StructuredAgentSessionCommandHandoverContext,
+ submission: AgentJournalSubmission,
+ fact: SubmissionRejectionFact,
+ refused: { state: 'rejected' } & AgentJournalDispatchRejection,
+ command: AgentSessionConversationCommand
+): Promise {
+ const { clientMessageId } = submission
+ const turn = structuredAgentSessionCommandTurn(clientMessageId)
+ const now = ctx.now()
+ await ctx.journal.appendLifecycleBatch({
+ settlementId: `command-settled:${clientMessageId}`,
+ fence: ctx.fence,
+ mutations: [
+ {
+ kind: 'item',
+ identity: turn.identity,
+ body: agentJournalTurnBody({
+ turnId: turn.turnId,
+ state: 'completed',
+ outcome: 'failure',
+ userItemId: agentJournalSubmissionKey(clientMessageId),
+ requestedAt: submission.submittedAt,
+ startedAt: now,
+ completedAt: now
+ }),
+ turnScope: AGENT_JOURNAL_THREAD_SCOPE
+ },
+ {
+ kind: 'item',
+ identity: turn.resultIdentity,
+ body: {
+ kind: 'status',
+ ...agentSessionFailureWords(fact, {
+ ...ctx.failureTextContext,
+ command,
+ surface: 'row'
+ }),
+ tone: 'error'
+ },
+ turnScope: { kind: 'turn', turnItemId: turn.itemId }
+ }
+ ]
+ })
+ await ctx.journal.resolveDispatch({ clientMessageId, ...refused, fence: ctx.fence })
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts
new file mode 100644
index 000000000000..f619112a2f31
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts
@@ -0,0 +1,549 @@
+// A /compact asked to wait (`delivery`) while the agent works: held as a card like a queued
+// send, answered at once, run when the queue drains it. Without the opt-in, today's refusal.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
+import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
+import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
+import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
+import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params'
+import {
+ createQueuedMessageTestRig,
+ eventually,
+ QUEUED_RIG_CALLER as CALLER,
+ type QueuedMessageTestRig
+} from './structured-agent-session-queued-message-rig.test-fixture'
+import {
+ HOST_TEST_SESSION as SESSION,
+ hostTestMessage,
+ hostTestOperationId
+} from './structured-agent-session-host-test-data'
+import {
+ QUEUED_MESSAGES_PERSON_RESERVE_BYTES,
+ QUEUED_MESSAGES_PUBLISHED_MAX_BYTES
+} from './structured-agent-session-queued-published-bytes'
+
+let rig: QueuedMessageTestRig
+
+beforeEach(async () => {
+ rig = await createQueuedMessageTestRig()
+})
+
+afterEach(() => rig.dispose())
+
+function compact(
+ delivery?: 'queue-if-active',
+ clientOperationId = hostTestOperationId(),
+ options?: { internal?: true }
+) {
+ const fields = { command: 'compact' as const, ...(delivery ? { delivery } : {}) }
+ return {
+ id: clientOperationId,
+ result: rig.host.conversationCommand(CALLER, {
+ envelope: rig.envelope(fields, 'agentSession.conversationCommand', clientOperationId),
+ ...fields,
+ ...(options?.internal ? {} : { userSend: true as const })
+ })
+ }
+}
+
+function clear() {
+ const fields = { command: 'clear' as const }
+ return rig.host.conversationCommand(CALLER, {
+ envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
+ ...fields
+ })
+}
+
+async function queuedCompact(): Promise {
+ const { id, result } = compact('queue-if-active')
+ expect(await result).toMatchObject({
+ ok: true,
+ value: { command: 'compact', state: 'completed', queued: { messageId: id, state: 'waiting' } }
+ })
+ return id
+}
+
+function prompt(state: 'pending' | 'resolved') {
+ const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
+ return journal.appendItem(
+ { provider: 'orca', clientMessageId: 'prompt-1' },
+ {
+ kind: 'approval',
+ title: 'Allow the tool?',
+ detail: null,
+ options: [],
+ resolution:
+ state === 'pending'
+ ? { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ : { state, selectedOptionId: 'allow', resolvedBy: 'client-1', resolvedAt: 1 }
+ },
+ { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
+ )
+}
+
+const BACKGROUND_TASK: AgentChildWorkView = {
+ id: 'child-dev',
+ providerId: 'task-dev',
+ kind: 'command',
+ description: 'npm run dev',
+ state: 'working',
+ membership: 'live',
+ firstObservedAt: 1,
+ observedAt: 1,
+ stoppable: true,
+ invocation: { invocationId: 'spawn-dev', generation: 1 }
+}
+
+const settleMs = () => new Promise((resolve) => setTimeout(resolve, 150))
+
+describe('a /compact that waits in line', () => {
+ it("uses the person's reserve when background cards fill their bound", async () => {
+ await rig.workingSend()
+ const backgroundRoom =
+ QUEUED_MESSAGES_PUBLISHED_MAX_BYTES - QUEUED_MESSAGES_PERSON_RESERVE_BYTES
+ const overhead = Buffer.byteLength(JSON.stringify(hostTestMessage('')), 'utf8')
+ const background = rig.send('x'.repeat(backgroundRoom - overhead), 'queue-if-active', {
+ internal: true
+ })
+ expect(await background.result).toMatchObject({
+ ok: true,
+ value: { queued: { messageId: background.id, state: 'waiting' } }
+ })
+ expect(
+ await compact('queue-if-active', hostTestOperationId(), { internal: true }).result
+ ).toMatchObject({
+ ok: false,
+ refusal: { details: { reason: 'queueTooLarge' } }
+ })
+ const compactId = await queuedCompact()
+ expect(await rig.drafts()).toEqual([
+ { messageId: background.id, state: 'waiting' },
+ { messageId: compactId, state: 'waiting' }
+ ])
+ expect(rig.compact).not.toHaveBeenCalled()
+ })
+
+ it('behind an unanswered message: a card at once, run once that message is answered', async () => {
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }])
+ await settleMs()
+ expect(rig.compact).not.toHaveBeenCalled()
+ await rig.settleAccepted(working, 'a')
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ expect((await rig.handoff(compactId))?.handedOverAt).toBeDefined()
+ expect(await rig.drafts()).toEqual([])
+ })
+
+ it('behind a running command turn: waits for that turn to end', async () => {
+ expect(await compact().result).toMatchObject({ ok: true, value: { state: 'completed' } })
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ const second = await queuedCompact()
+ await settleMs()
+ expect(rig.compact).toHaveBeenCalledOnce()
+ rig.finishCompact()
+ await eventually(() => expect(rig.compact).toHaveBeenCalledTimes(2))
+ expect((await rig.handoff(second))?.handedOverAt).toBeDefined()
+ })
+
+ it('behind a pending question or approval: waits for its answer', async () => {
+ await prompt('pending')
+ const compactId = await queuedCompact()
+ await settleMs()
+ expect(rig.compact).not.toHaveBeenCalled()
+ await prompt('resolved')
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ expect(await rig.handoff(compactId)).toBeDefined()
+ })
+
+ it('behind a message the agent refuses: still runs, nothing strands', async () => {
+ const working = await rig.workingSend()
+ await queuedCompact()
+ await rig.settleRejected(working, 'provider refused this payload')
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ })
+
+ it('a resent id answers from its card, then from the submission it became; one run', async () => {
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ expect(await compact('queue-if-active', compactId).result).toMatchObject({
+ ok: true,
+ value: { queued: { messageId: compactId, state: 'waiting' } }
+ })
+ expect(await rig.drafts()).toHaveLength(1)
+ await rig.settleAccepted(working, 'a')
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ const resent = await compact('queue-if-active', compactId).result
+ expect(resent).toMatchObject({ ok: true, value: { command: 'compact', state: 'completed' } })
+ expect(resent.ok && resent.value.queued).toBeFalsy()
+ await settleMs()
+ expect(rig.compact).toHaveBeenCalledOnce()
+ expect(
+ (await rig.host.journalSnapshot(SESSION)).submissions.filter(
+ (entry) => entry.queuedMessageId === compactId
+ )
+ ).toHaveLength(1)
+ })
+
+ it('never steers: Send-now on its card mid-turn is refused and it keeps waiting', async () => {
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ expect(await rig.sendNow(compactId)).toMatchObject({
+ ok: false,
+ refusal: { message: "A command can't be sent while the agent is working." }
+ })
+ expect(await rig.handoff(compactId)).toBeUndefined()
+ await rig.settleAccepted(working, 'a')
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ })
+
+ it('a later send waits behind it, in the order sent', async () => {
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ const later = await rig.send('sent after the compact', 'queue-if-active').result
+ if (!later.ok || !('queued' in later.value)) {
+ throw new Error('expected a queued receipt')
+ }
+ const laterId = later.value.queued.messageId
+ await rig.settleAccepted(working, 'a')
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ // The compaction's turn runs, so the later card still waits.
+ await settleMs()
+ expect(await rig.handoff(laterId)).toBeUndefined()
+ rig.finishCompact()
+ await eventually(async () => expect(await rig.handoff(laterId)).toBeDefined())
+ expect(
+ (await rig.host.journalSnapshot(SESSION)).submissions.flatMap((entry) =>
+ entry.queuedMessageId ? [entry.queuedMessageId] : []
+ )
+ ).toEqual([compactId, laterId])
+ })
+
+ it('at rest, runs at once as before', async () => {
+ const { result } = compact('queue-if-active')
+ const answer = await result
+ expect(answer).toMatchObject({ ok: true, value: { command: 'compact', state: 'completed' } })
+ expect(answer.ok && answer.value.queued).toBeFalsy()
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ expect(await rig.drafts()).toEqual([])
+ })
+
+ it('without the opt-in (an older client), is refused while a message is unanswered, as today', async () => {
+ await rig.workingSend()
+ expect(await compact().result).toMatchObject({
+ ok: false,
+ refusal: { details: { reason: 'messagesUnsettled' } }
+ })
+ expect(await rig.drafts()).toEqual([])
+ })
+
+ it('Delete takes it back: it never runs', async () => {
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ expect(await rig.deleteQueued(compactId)).toMatchObject({ ok: true, value: { deleted: true } })
+ await rig.settleAccepted(working, 'a')
+ await settleMs()
+ expect(rig.compact).not.toHaveBeenCalled()
+ expect(await rig.drafts()).toEqual([])
+ })
+
+ it('Stop pauses it with the queue, and Resume runs it', async () => {
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ await rig.stop()
+ await rig.settleAccepted(working, 'a')
+ await settleMs()
+ expect(rig.compact).not.toHaveBeenCalled()
+ expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
+ expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }])
+ expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } })
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ })
+
+ /** The rows that report a failure, and the cards still listed. */
+ async function failureRowsAndCards() {
+ const snapshot = await rig.host.journalSnapshot(SESSION)
+ return {
+ failures: snapshot.items.filter(
+ (item) => item.body.kind === 'status' && item.body.tone === 'error'
+ ),
+ cards: await rig.drafts()
+ }
+ }
+
+ it('refused by the agent: spent, said once in its turn, and the next card still runs', async () => {
+ // Every compaction is refused, as on a short chat: a returned card would loop or block.
+ rig.compact.mockResolvedValue({
+ state: 'rejected',
+ ...agentSessionFailureWords(
+ agentSessionFailureFact('providerRejected', {
+ detail: { text: 'Not enough messages to compact.', audience: 'person' }
+ }),
+ { surface: 'rejection' }
+ )
+ })
+ const working = await rig.workingSend()
+ await queuedCompact()
+ const later = await rig.send('sent after the compact', 'queue-if-active').result
+ if (!later.ok || !('queued' in later.value)) {
+ throw new Error('expected a queued receipt')
+ }
+ await rig.settleAccepted(working, 'a')
+ const laterId = later.value.queued.messageId
+ await eventually(async () => expect(await rig.handoff(laterId)).toBeDefined())
+ const { failures, cards } = await failureRowsAndCards()
+ expect(failures).toHaveLength(1)
+ expect(JSON.stringify(failures[0]!.body)).toContain('Not enough messages to compact.')
+ expect(cards).toEqual([])
+ expect(rig.compact).toHaveBeenCalledOnce()
+ })
+
+ it('refused before it starts (background tasks): one row says so, and the next card runs', async () => {
+ let tasks: AgentChildWorkView[] = []
+ Object.assign(rig.host.deps, {
+ statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => tasks }
+ })
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ const later = await rig.send('sent after the compact', 'queue-if-active').result
+ if (!later.ok || !('queued' in later.value)) {
+ throw new Error('expected a queued receipt')
+ }
+ tasks = [BACKGROUND_TASK]
+ await rig.settleAccepted(working, 'a')
+ const laterId = later.value.queued.messageId
+ await eventually(async () => expect(await rig.handoff(laterId)).toBeDefined())
+ expect(rig.compact).not.toHaveBeenCalled()
+ // Refused while still queued: no hand-off was recorded for a command that never left.
+ const refused = await rig.handoff(compactId)
+ expect(refused).toMatchObject({ dispatchState: 'rejected' })
+ expect(refused?.handedOverAt).toBeUndefined()
+ const { failures, cards } = await failureRowsAndCards()
+ expect(failures).toHaveLength(1)
+ // The reason, as the direct path says it, not a bare "try it again".
+ expect(failures[0]!.body).toMatchObject({
+ text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.'
+ })
+ expect(cards).toEqual([])
+ })
+
+ it('refused before it starts, a failed rejection write never drops the card without its row', async () => {
+ let tasks: AgentChildWorkView[] = []
+ Object.assign(rig.host.deps, {
+ statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => tasks }
+ })
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal
+ const resolve = journal.resolveDispatch.bind(journal)
+ const failed = vi.fn()
+ vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (input) => {
+ if (input.state === 'rejected' && !failed.mock.calls.length) {
+ failed()
+ throw new Error('disk full')
+ }
+ return resolve(input)
+ })
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ tasks = [BACKGROUND_TASK]
+ await rig.settleAccepted(working, 'a')
+ await eventually(() => expect(failed).toHaveBeenCalledOnce())
+ // The refusal's row landed before the write that failed, so the card never left without it;
+ // the loop then reports its own fault, and nothing was ever handed over.
+ const { failures } = await failureRowsAndCards()
+ expect(failures.map((row) => row.body)).toContainEqual(
+ expect.objectContaining({
+ text: 'Background tasks are still running. Wait for the background tasks to finish. Run /compact again.'
+ })
+ )
+ expect((await rig.handoff(compactId))?.handedOverAt).toBeUndefined()
+ expect(rig.compact).not.toHaveBeenCalled()
+ })
+
+ it('a failed agent start returns its card like any card, never spends it', async () => {
+ await rig.dispose()
+ rig = await createQueuedMessageTestRig({ restartable: true })
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ await rig.stop()
+ await rig.settleAccepted(working, 'a')
+ // The chat's agent went away; the next start fails.
+ await rig.restartHostProcess()
+ rig.failNextStart(new Error('the agent could not start'))
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ expect(await rig.resume()).toMatchObject({ ok: true })
+ await eventually(async () =>
+ expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'returned' }])
+ )
+ expect(rig.compact).not.toHaveBeenCalled()
+ })
+
+ it('Send on its card works whenever the agent is idle, as after a paused queue', async () => {
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ await rig.stop()
+ await rig.settleAccepted(working, 'a')
+ expect(await rig.sendNow(compactId)).toMatchObject({
+ ok: true,
+ value: { submission: { queuedMessageId: compactId } }
+ })
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ })
+
+ /** A command card consumed while the agent starts, and never handed over: then Orca crashes. */
+ async function consumedThenCrashed(send: 'drain' | 'sendNow'): Promise {
+ await rig.dispose()
+ rig = await createQueuedMessageTestRig({ restartable: true })
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ await rig.stop()
+ await rig.settleAccepted(working, 'a')
+ // No child now, and the next start stays in its spawn: the hand-off stays queued.
+ await rig.host.close(SESSION, 'evict')
+ const release = rig.holdNextStart()
+ const startsBefore = rig.starts.mock.calls.length
+ if (send === 'drain') {
+ expect(await rig.resume()).toMatchObject({ ok: true })
+ } else {
+ expect(await rig.sendNow(compactId)).toMatchObject({ ok: true })
+ }
+ await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore))
+ rig.crashRestartHostProcess()
+ release()
+ return compactId
+ }
+
+ it('cut short by a restart after the queue sent it: waits again under the restart, never spent', async () => {
+ const compactId = await consumedThenCrashed('drain')
+ // The queue's own hand-off is not the person's, so it waits as any queued card does after a
+ // restart: held unshown, never sent by itself.
+ await eventually(async () =>
+ expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }])
+ )
+ expect(await rig.queuePause()).toBeNull()
+ expect(rig.compact).not.toHaveBeenCalled()
+ })
+
+ it('cut short by a restart after the person sent it: waits again, never spent', async () => {
+ const compactId = await consumedThenCrashed('sendNow')
+ expect(await rig.drafts()).toEqual([{ messageId: compactId, state: 'waiting' }])
+ expect(await rig.queuePause()).toBeNull()
+ const card = rig.host
+ .collaboratorsForTests()
+ .sessions.get(SESSION)
+ ?.journal.queuedMessages.get(compactId)
+ expect(card).toMatchObject({
+ holdReason: null,
+ body: { command: { name: 'compact' } }
+ })
+ expect(rig.compact).not.toHaveBeenCalled()
+ })
+
+ it('a kept message stays ahead of a waiting /compact after the next turn releases both', async () => {
+ await rig.dispose()
+ rig = await createQueuedMessageTestRig({ restartable: true })
+ const working = await rig.workingSend()
+ const compactId = await queuedCompact()
+ await rig.stop()
+ await rig.settleAccepted(working, 'a')
+ await rig.host.close(SESSION, 'evict')
+ const release = rig.holdNextStart()
+ const startsBefore = rig.starts.mock.calls.length
+ const kept = rig.send('kept message')
+ await kept.result
+ await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore))
+ // Finish the old host before reopening so its delayed start cannot keep writing.
+ const restarted = rig.quitRestartHostProcess()
+ release()
+ await restarted
+
+ expect(await rig.drafts()).toEqual([
+ { messageId: kept.id, state: 'waiting' },
+ { messageId: compactId, state: 'waiting' }
+ ])
+ expect(rig.compact).not.toHaveBeenCalled()
+ const next = rig.send('next turn', 'queue-if-active')
+ expect(await next.result).toMatchObject({ ok: true, value: { submission: expect.anything() } })
+ await eventually(async () =>
+ expect((await rig.submission(next.id))?.handedOverAt).toBeDefined()
+ )
+ await rig.settleAccepted(next.id, 'next')
+ await eventually(async () => expect((await rig.handoff(kept.id))?.handedOverAt).toBeDefined())
+ expect(rig.dispatch.mock.calls.at(-1)?.[0].body.blocks).toEqual([
+ { type: 'text', text: 'kept message' }
+ ])
+ expect(rig.compact).not.toHaveBeenCalled()
+ await rig.settleAccepted(await rig.handoffId(kept.id), 'kept')
+ await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
+ expect(await rig.drafts()).toEqual([])
+ })
+
+ it('a direct /compact (no opt-in, as on a host without the queue) cut short by a restart is never a card', async () => {
+ await rig.dispose()
+ rig = await createQueuedMessageTestRig({ restartable: true })
+ await rig.workingSend()
+ await rig.host.close(SESSION, 'evict')
+ const release = rig.holdNextStart()
+ const startsBefore = rig.starts.mock.calls.length
+ const { id } = compact()
+ await eventually(() => expect(rig.starts.mock.calls.length).toBeGreaterThan(startsBefore))
+ rig.crashRestartHostProcess()
+ release()
+ // A command in flight is not resumed: the person runs it again. No card, kept or otherwise.
+ expect(await rig.drafts()).toEqual([])
+ expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected' })
+ })
+
+ it('a /clear drops a waiting command card instead of carrying it to the new chat', async () => {
+ const working = await rig.workingSend()
+ await queuedCompact()
+ const draft = await rig.send('carried draft', 'queue-if-active').result
+ if (!draft.ok || !('queued' in draft.value)) {
+ throw new Error('expected a queued receipt')
+ }
+ await rig.stop()
+ await rig.settleAccepted(working, 'a')
+ const cleared = await clear()
+ const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
+ if (!replacementId) {
+ throw new Error('expected a replacement session')
+ }
+ expect(await rig.drafts()).toEqual([])
+ expect(await rig.drafts(replacementId)).toEqual([
+ { messageId: draft.value.queued.messageId, state: 'waiting' }
+ ])
+ expect(rig.compact).not.toHaveBeenCalled()
+ })
+
+ it('a /clear with only a command card waiting opens no replacement conversation for it', async () => {
+ const working = await rig.workingSend()
+ await queuedCompact()
+ await rig.stop()
+ await rig.settleAccepted(working, 'a')
+ const cleared = await clear()
+ const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
+ if (!replacementId) {
+ throw new Error('expected a replacement session')
+ }
+ expect(await rig.drafts()).toEqual([])
+ expect(rig.host.collaboratorsForTests().sessions.has(replacementId)).toBe(false)
+ })
+})
+
+it('only a /compact may ask to wait; a /clear sent asking to is refused, never queued', async () => {
+ const base = { envelope: rig.envelope({}, 'agentSession.conversationCommand', 'op-schema') }
+ const parse = (fields: Record) =>
+ ConversationCommandParams.safeParse({ ...base, ...fields }).success
+ expect(parse({ command: 'compact', delivery: 'queue-if-active' })).toBe(true)
+ expect(parse({ command: 'compact', delivery: 'now' })).toBe(false)
+ await rig.workingSend()
+ const fields = { command: 'clear' as const, delivery: 'queue-if-active' as const }
+ expect(
+ await rig.host.conversationCommand(CALLER, {
+ envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
+ ...fields
+ })
+ ).toMatchObject({ ok: false })
+ expect(await rig.drafts()).toEqual([])
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts
index ccabd39503e6..83738cf91a4c 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts
@@ -36,6 +36,7 @@ export function createQueuedRigProvider(
// Every start the host asks for; one `holdNextStart` holds stays in its spawn until released.
const starts: Mock<() => void> = vi.fn()
let startHold: Promise | null = null
+ let startFailure: Error | null = null
// The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it.
const compact: Mock> = vi.fn(async () => ({
state: 'accepted' as const,
@@ -55,6 +56,11 @@ export function createQueuedRigProvider(
const hold = startHold
startHold = null
await hold
+ const failure = startFailure
+ startFailure = null
+ if (failure) {
+ throw failure
+ }
events = sink
const resumes =
options.restartable === true &&
@@ -112,6 +118,11 @@ export function createQueuedRigProvider(
return () => release()
}
+ /** Fails the next start in its spawn, as an agent that cannot start does. */
+ function failNextStart(error: Error): void {
+ startFailure = error
+ }
+
/** The event sink the provider writes through. */
function providerEvents(): StructuredAgentSessionEventSink {
if (!events) {
@@ -125,6 +136,7 @@ export function createQueuedRigProvider(
dispatch,
starts,
holdNextStart,
+ failNextStart,
compact,
cancelTurn,
closeSession,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts
index 90f2a96acba3..fb4cf151174d 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts
@@ -285,6 +285,7 @@ export async function createQueuedMessageTestRig(
compact,
starts: provider.starts,
holdNextStart: provider.holdNextStart,
+ failNextStart: provider.failNextStart,
finishCompact: provider.finishCompact,
providerEvents: provider.providerEvents,
envelope,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
index 23a8af3582a6..249396d38b5d 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
@@ -78,8 +78,8 @@ function oldestActionableQueuedMessage(
* hold, or an actionable backlog, queues the send as a draft.
* drain step: any hold returns early; whatever clears it publishes or
* commits, which re-derives.
- * Send-now: overrides only `working` (plus FIFO order and the stored hold);
- * `blocked` and `prompt` refuse readably.
+ * Send-now: overrides only `working` (plus FIFO order and the stored hold),
+ * never for a command card; `blocked` and `prompt` refuse readably.
*
* `blocked` is whatever refuses any send (an uncertain rewind, a cleared source);
* the rest are waits. A /compact is a queued message and then a turn,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts
index e20bd60b9eba..f57723ed61c7 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts
@@ -82,7 +82,8 @@ export async function withdrawQueuedMessagesForOperation(
* came from, which IS that pause, so the drain never sees a carried card unpaused
* and no pause outlives the cards. Runs after the clear commits, opening the
* replacement's conversation only when there are drafts to carry; the source
- * rows are then tombstoned. Bookkeeping around the clear: a failure, or a crash
+ * rows are then tombstoned. A command card (a waiting /compact) is tombstoned
+ * without a copy. Bookkeeping around the clear: a failure, or a crash
* before the carry, leaves the cards on the superseded source — whose
* supersession fence already blocks the drain — reported, never gating the
* clear. A crash between the copy and the tombstone leaves both, which the
@@ -102,21 +103,25 @@ export async function carryQueuedMessagesToClearReplacement(
if (rows.length === 0) {
return
}
- const replacement = await input.openReplacementJournal()
- if (!replacement) {
- throw new Error('the replacement journal is not open')
- }
- for (const row of rows) {
- // A returned card carries over as a plain waiting draft — its refusal
- // belonged to the source's submissions. The fingerprint is re-scoped to the
- // replacement, or its echo could never alias the sent bubble.
- await replacement.queuedMessages.insert({
- messageId: row.messageId,
- body: row.body,
- fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body),
- hostInstance: structuredAgentSessionHostInstance(),
- carriedFrom: ctx.sessionId
- })
+ // A command belongs to the discarded context, so it is withdrawn instead of carried.
+ const carried = rows.filter((row) => !row.body.command)
+ if (carried.length > 0) {
+ const replacement = await input.openReplacementJournal()
+ if (!replacement) {
+ throw new Error('the replacement journal is not open')
+ }
+ for (const row of carried) {
+ // A returned card carries over as a plain waiting draft — its refusal
+ // belonged to the source's submissions. The fingerprint is re-scoped to the
+ // replacement, or its echo could never alias the sent bubble.
+ await replacement.queuedMessages.insert({
+ messageId: row.messageId,
+ body: row.body,
+ fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body),
+ hostInstance: structuredAgentSessionHostInstance(),
+ carriedFrom: ctx.sessionId
+ })
+ }
}
await withdrawQueuedMessagesForOperation(ctx.journal, {
sessionId: ctx.sessionId,
@@ -214,6 +219,11 @@ export function sendQueuedStructuredAgentMessage(
? { ok: true, value: { clientMessageId: submission.clientMessageId, submission } }
: invalid('This queued message was already sent.')
}
+ // A command never steers: handed over mid-turn it would only be refused. Clients offer its
+ // Send only while the agent is idle; this answers an older one that offers it mid-turn.
+ if (hold === 'working' && row.body.command) {
+ return invalid("A command can't be sent while the agent is working.")
+ }
const submissionId = operationId
try {
await ctx.journal.appendSubmission(
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
index 3cd2417eb72e..a5991fa2f1fc 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
@@ -187,6 +187,8 @@ async function params(itemId: string, epoch?: string) {
}
}
+const LATE = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'late', ordinal: 0 }
+
describe('host rewind', () => {
it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => {
await expectRawStopNoteRewindRecovery({ host, store, rewind })
@@ -323,6 +325,41 @@ describe('host rewind', () => {
})
expect(rewind).not.toHaveBeenCalled()
})
+ it('is busy exactly while the chat shows a sent message unanswered', async () => {
+ const target = await seed()
+ const body = hostTestMessage('unanswered')
+ const clientOperationId = hostTestOperationId()
+ // Accepted: the wait below sees its submission settle in doubt.
+ await host.send(caller, {
+ body,
+ envelope: {
+ ...(await params(target)).envelope,
+ clientOperationId,
+ payloadFingerprint: computeAgentSessionPayloadFingerprint({
+ method: 'agentSession.send',
+ sessionId: HOST_TEST_SESSION,
+ fields: { body }
+ })
+ }
+ })
+ // The adapter's reply is lost: a live doubt, which the chat shows as working.
+ const sent = async () =>
+ (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find(
+ (entry) => entry.clientMessageId === clientOperationId
+ )
+ await vi.waitFor(async () => expect((await sent())?.dispatchState).toBe('unknown'))
+ expect(await host.rewind(caller, await params(target))).toMatchObject({
+ ok: false,
+ refusal: { rewindReason: 'busy' }
+ })
+ await host.settleLateDispatch({
+ sessionId: HOST_TEST_SESSION,
+ clientMessageId: clientOperationId,
+ providerIdentity: LATE
+ })
+ expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true })
+ expect(rewind).toHaveBeenCalledOnce()
+ })
it('refuses stale epochs and targets from another provider', async () => {
const target = await seed()
expect(await host.rewind(caller, await params(target, 'old-epoch'))).toMatchObject({
diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts
index 5c2ff3662437..94fe8f4120b2 100644
--- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts
@@ -6,6 +6,8 @@ import {
type ConversationCommandAdmissionContext
} from './structured-conversation-command-admission'
import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets'
+import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
+import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch'
const TARGETED: AgentSessionBackgroundTaskStops = { supportsTaskStop: true, supportsStopAll: true }
const UNTARGETED: AgentSessionBackgroundTaskStops = {
@@ -192,3 +194,66 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () =>
expect(conversationCommandBlocked(ctx, RECORD, [], 'handover')).toBeNull()
})
})
+
+describe('conversationCommandBlocked on unsettled messages', () => {
+ // One send per state the Working indicator and admission could read differently.
+ const SENDS: Record> = {
+ queued: { dispatchState: 'pending', handoverRecorded: true },
+ handedOver: { dispatchState: 'pending', handoverRecorded: true, handedOverAt: 5 },
+ handedOverByEarlierChild: {
+ dispatchState: 'pending',
+ handoverRecorded: true,
+ handedOverAt: 5,
+ fence: 0
+ },
+ liveUnknown: { dispatchState: 'unknown' },
+ recoveredUnknown: { dispatchState: 'unknown', recovered: true },
+ restartedUnknown: { dispatchState: 'unknown', reason: 'host_restarted_before_acknowledgement' },
+ accepted: { dispatchState: 'accepted' },
+ rejected: { dispatchState: 'rejected' }
+ }
+
+ function admissionOver(send: string, admission?: 'at-rest' | 'handover') {
+ const ctx = contextWith(undefined)
+ const submissions: AgentJournalSubmission[] = [
+ {
+ clientMessageId: send,
+ fence: 1,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'pending',
+ providerItemId: null,
+ reason: null,
+ submittedAt: 1,
+ resolvedAt: null,
+ ...SENDS[send]
+ }
+ ]
+ ctx.journal.submissions = () => submissions
+ const refusal = conversationCommandBlocked(ctx, RECORD, [], admission)
+ return {
+ blocked: refusal?.details && 'reason' in refusal.details ? refusal.details.reason : null,
+ working: hasUnansweredStructuredAgentSessionDispatch(submissions, ctx.fence)
+ }
+ }
+
+ it.each(Object.keys(SENDS))(
+ 'refuses on %s exactly when the chat shows the agent working',
+ (send) => {
+ const { blocked, working } = admissionOver(send)
+ expect(blocked).toBe(working ? 'messagesUnsettled' : null)
+ expect(admissionOver(send, 'at-rest').blocked).toBe(blocked)
+ }
+ )
+
+ it('does not refuse what the chat no longer shows as working', () => {
+ // An earlier child's hand-off and a restart's doubt: the chat reads idle, so nothing refuses.
+ expect(admissionOver('handedOverByEarlierChild').blocked).toBeNull()
+ expect(admissionOver('restartedUnknown').blocked).toBeNull()
+ })
+
+ it('at handover, a queued message is one behind the command; a handed-over one still refuses', () => {
+ expect(admissionOver('queued', 'handover').blocked).toBeNull()
+ expect(admissionOver('handedOver', 'handover').blocked).toBe('messagesUnsettled')
+ expect(admissionOver('liveUnknown', 'handover').blocked).toBe('messagesUnsettled')
+ })
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts
index dfb4c48f1a9c..d20dcd45e7a0 100644
--- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts
+++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts
@@ -8,6 +8,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu
import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
+import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import {
@@ -114,14 +115,16 @@ export function conversationCommandBlocked(
: 'Wait for background tasks to finish before using this command.'
)
}
+ // The Working indicator's own rule, so "unsettled" is exactly what the chat shows as working.
+ // At handover the queued messages are those behind this command, waiting for it.
if (
- ctx.journal.submissions().some(
- (entry) =>
- (entry.dispatchState === 'pending' &&
- !(admission === 'handover' && isQueuedAgentJournalSubmission(entry))) ||
- // Doubt left by an earlier child is not this one's work in flight.
- (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence)
- )
+ ctx.journal
+ .submissions()
+ .some(
+ (entry) =>
+ !(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) &&
+ isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence)
+ )
) {
return blocked(
'messagesUnsettled',
diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts
index 764b569d8ff5..95bdad666e0a 100644
--- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts
+++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts
@@ -39,6 +39,10 @@ export function conversationCommandFailure(
export type ConversationCommandParams = {
envelope: AgentSessionMutationEnvelope
command: AgentSessionConversationCommand
+ /** /compact only: wait as a card while the agent works, as a queued send does. */
+ delivery?: 'queue-if-active'
+ /** Host-local, set by the client-facing command RPC as for an ordinary send. */
+ userSend?: true
}
export type ConversationReplacement = {
sourceSessionId: string
diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts
index a5db53e5555c..ffaf2104b410 100644
--- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts
@@ -392,7 +392,12 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B
}
await expect(commanded).resolves.toMatchObject({
ok: true,
- value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused }
+ // The reason, said as its refusal is everywhere, not a bare "try it again".
+ value: {
+ state: 'completed',
+ error: "The agent is still working. Run /compact when it's done.",
+ failure: refused
+ }
})
expect(compact).not.toHaveBeenCalled()
expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined()
@@ -402,7 +407,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B
)
).toMatchObject({
dispatchState: 'rejected',
- reason: "This command didn't run. Try it again.",
+ reason: "The agent is still working. Run /compact when it's done.",
rejection: refused
})
})
diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts
index 98c04a2e8401..c12f3d413692 100644
--- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts
+++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts
@@ -1,5 +1,6 @@
// `/compact` sent through the command RPC: accepted into the conversation as the user's message,
-// and answered once the delivery loop hands it over.
+// and answered once the delivery loop hands it over. Asked with `delivery` while the agent works,
+// it waits as a card like a queued send, answered at once.
import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command'
import {
@@ -9,10 +10,7 @@ import {
import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words'
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
-import type {
- AgentSessionMutationEnvelope,
- AgentSessionMutationResult
-} from '../../../shared/agent-session-wire'
+import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
import {
mutateStructuredAgentSession,
@@ -34,6 +32,8 @@ import {
type ConversationCommandParams
} from './structured-conversation-command'
import { conversationCommandBlocked } from './structured-conversation-command-admission'
+import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages'
+import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn'
/**
* `/compact` from a client that asks through the command RPC: accepted into the conversation like
@@ -68,6 +68,13 @@ export async function runStructuredCompaction(
if ('recorded' in accepted.value) {
return { ...accepted, value: accepted.value.recorded }
}
+ if ('queued' in accepted.value) {
+ // The card is the one surface from here: a refusal in its own turn is said there, once.
+ return {
+ ...accepted,
+ value: { command: 'compact', state: 'completed', queued: accepted.value.queued }
+ }
+ }
const settled = await host.waitForSendSettlement(sessionId, accepted.value.clientMessageId, {
until: 'handed-over',
budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS
@@ -123,10 +130,14 @@ function compactionReply(
function acceptStructuredConversationCommand(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
- params: { envelope: AgentSessionMutationEnvelope },
+ params: ConversationCommandParams,
priorRecord: () => AgentSessionConversationCommandResult | null
): Promise> {
- const plan = conversationCommandPlan({ envelope: params.envelope, priorRecord })
+ const plan = conversationCommandPlan({
+ envelope: params.envelope,
+ ...(params.delivery ? { delivery: params.delivery } : {}),
+ priorRecord
+ })
return mutateStructuredAgentSession(
context,
caller,
@@ -134,6 +145,28 @@ function acceptStructuredConversationCommand(
{
...plan,
run: async (ctx) => {
+ // The queue's own accept rule decides first: whatever a queued send waits behind, the
+ // command waits behind too, so admission below never sees work in flight for it.
+ const queued = await maybeQueueStructuredAgentSessionSend(context, ctx, {
+ envelope: params.envelope,
+ body: structuredAgentSessionCompactBody(),
+ ...(params.userSend ? { userSend: params.userSend } : {}),
+ ...(params.delivery ? { delivery: params.delivery } : {})
+ })
+ if (queued && !queued.ok) {
+ return queued
+ }
+ if (queued) {
+ // A card already drained answers from the submission it became.
+ const { value } = queued
+ return {
+ ok: true,
+ value:
+ 'queued' in value
+ ? { queued: value.queued }
+ : { clientMessageId: value.submission.clientMessageId }
+ }
+ }
const record = context.deps.store.getRecord(ctx.sessionId)
const refusal =
record &&
diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts
index 7324ae7ffd2e..17dbdd49a0ae 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session.ts
@@ -118,7 +118,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
await ensureHostInstalled(ctx)
const host = requireSessionHost(ctx, params.envelope.sessionId)
await host.revealSession(params.envelope.sessionId)
- const result = await host.conversationCommand(callerFor(ctx), params)
+ const result = await host.conversationCommand(callerFor(ctx), { ...params, userSend: true })
if (result.ok && result.value.command === 'clear' && result.value.replacementSessionId) {
const replacement = host
.conversationReplacements()
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index 8c09a70a2d1c..920e76d4c50c 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -59,10 +59,16 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null
// A card's own hold; the queue's pause is the list's header. Markers localize, and an absent
// or unknown one (newer host) is a plain pause, never shown raw.
if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) {
- return translate(
- 'components.native-chat.queuedMessages.pausedSendFailed',
- "Couldn't send — press Send to retry."
- )
+ // Its Send shows only while the agent is idle; the words match what is on the card.
+ return card.waitsForAgent
+ ? translate(
+ 'components.native-chat.queuedMessages.pausedSendFailedWaiting',
+ "Couldn't send — press Send to retry once the agent finishes."
+ )
+ : translate(
+ 'components.native-chat.queuedMessages.pausedSendFailed',
+ "Couldn't send — press Send to retry."
+ )
}
return translate('components.native-chat.queuedMessages.paused', 'Paused')
case 'behind-returned':
@@ -75,6 +81,8 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null
'components.native-chat.queuedMessages.awaitingAnswerHold',
'Waiting for your answer'
)
+ case 'sending':
+ return translate('components.native-chat.messageSending', 'Sending…')
case 'turn':
case 'queue-paused':
// Plainly queued; a paused queue's header row carries the why.
@@ -162,67 +170,88 @@ export function NativeChatQueuedMessageCard({
) : null}
-
-
-
-
-
-
- {sendNow.hint}
- {showsSteerShortcut ? (
-
- ) : null}
-
-
-
-
-
-
-
-
- {translate('components.native-chat.queuedMessages.delete', 'Delete')}
-
-
-
-
-
-
-
-
-
- {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
-
- {onTurnOffQueueing ? (
-
- {translate(
- 'components.native-chat.queuedMessages.turnOffQueueing',
- 'Turn off queueing'
+ {/* Nothing acts on a send still on its way: the host holds no card for it yet. */}
+ {card.hold === 'sending' ? null : (
+ <>
+ {/* A command never steers: its Send shows only while the agent is idle. */}
+ {card.waitsForAgent ? null : (
+
+
+
+
+
+
+ {sendNow.hint}
+ {showsSteerShortcut ? (
+
+ ) : null}
+
+
+
+ )}
+
+
+
+
+
+ {translate('components.native-chat.queuedMessages.delete', 'Delete')}
+
+
+
+
+
+
+
+ {/* A command's text is not a draft: edited, it would become a message. */}
+ {card.command ? null : (
+
+
+ {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
+
)}
-
- ) : null}
-
-
+ {onTurnOffQueueing ? (
+
+ {translate(
+ 'components.native-chat.queuedMessages.turnOffQueueing',
+ 'Turn off queueing'
+ )}
+
+ ) : null}
+
+
+ >
+ )}
)
}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
index b914b31196b4..b795eeb625f3 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx
@@ -170,6 +170,65 @@ describe('NativeChatQueuedMessageList', () => {
expect(owner.remove).toHaveBeenCalledWith('draft-2')
})
+ it('a command card never steers: Send only while the agent is idle', () => {
+ const owner = controller([
+ card({ messageId: 'compact-1', text: '/compact', command: true, waitsForAgent: true }),
+ card({ messageId: 'compact-2', text: '/compact', command: true, hold: 'paused' })
+ ])
+ renderList(owner)
+ expect(screen.getAllByText('/compact')).toHaveLength(2)
+ expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }))
+ expect(owner.steer).toHaveBeenCalledWith('compact-2')
+ expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(2)
+ })
+
+ it('a send on its way reads Sending and offers nothing until the host holds it', () => {
+ renderList(controller([card({ messageId: 'sending-1', text: 'on its way', hold: 'sending' })]))
+ expect(screen.getByText('Sending…')).toBeTruthy()
+ expect(screen.queryAllByRole('button')).toHaveLength(0)
+ })
+
+ it("a send-failed command card's caption names Send only when Send is there", () => {
+ const failed = { command: true as const, hold: 'paused' as const, pausedReason: 'send_failed' }
+ renderList(
+ controller([
+ card({ messageId: 'working', text: '/compact', ...failed, waitsForAgent: true }),
+ card({ messageId: 'idle', text: '/compact', ...failed })
+ ])
+ )
+ expect(
+ screen.getByText("Couldn't send — press Send to retry once the agent finishes.")
+ ).toBeTruthy()
+ expect(screen.getByText("Couldn't send — press Send to retry.")).toBeTruthy()
+ expect(screen.getAllByRole('button', { name: 'Send' })).toHaveLength(1)
+ })
+
+ it('an idle command card waiting its turn reads Send, not Steer', () => {
+ renderList(controller([card({ messageId: 'compact-1', text: '/compact', command: true })]))
+ expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy()
+ expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull()
+ })
+
+ it('a command card offers no Edit: its text is not a draft', async () => {
+ renderList(controller([card({ messageId: 'compact-1', text: '/compact', command: true })]))
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' }))
+ expect(await screen.findByRole('menuitem', { name: 'Turn off queueing' })).toBeTruthy()
+ expect(screen.queryByRole('menuitem', { name: 'Edit message' })).toBeNull()
+ })
+
+ it("a paused command card's ways out are Delete and the queue's Resume", () => {
+ const owner = controller(
+ [card({ messageId: 'compact-1', text: '/compact', command: true, hold: 'queue-paused' })],
+ { reason: 'stopped' }
+ )
+ renderList(owner)
+ fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
+ expect(owner.resume).toHaveBeenCalledOnce()
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+ expect(owner.remove).toHaveBeenCalledWith('compact-1')
+ })
+
it("holds every card's Steer while a person's Stop ends the turn; Delete still works", () => {
const owner = controller([card({ messageId: 'draft-1', position: 1 })])
render(
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx
new file mode 100644
index 000000000000..8d56bcd32330
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.command-refusal-line.test.tsx
@@ -0,0 +1,96 @@
+// @vitest-environment happy-dom
+
+import { act, cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { StructuredAgentSessionCommandRefusalCause } from '../../../../shared/structured-agent-session-composer'
+
+const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
+ (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
+)
+
+vi.mock('@/runtime/structured-agent-session-client', () =>
+ moduleFactories.structuredAgentSessionClient()
+)
+vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
+vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize())
+vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
+vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
+vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
+vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
+vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
+vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
+
+import { NativeChatStructuredSession } from './NativeChatStructuredSession'
+
+afterEach(() => {
+ cleanup()
+ resetStructuredSessionMocks()
+})
+
+const STILL_WORKING = "The agent is still working. Run /clear when it's done."
+const BACKGROUND =
+ 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.'
+
+function renderPane() {
+ const pane = () => (
+
+ )
+ const view = render(pane())
+ // A fresh element: the mocked controller reads the chat's state only when the pane renders.
+ return { rerender: () => view.rerender(pane()) }
+}
+
+function composerSays(
+ text: string | null,
+ refusedWhile?: StructuredAgentSessionCommandRefusalCause
+) {
+ const onError = mocks.composerProps?.structuredTransport?.onError
+ if (typeof onError !== 'function') {
+ throw new Error('the composer has no transport')
+ }
+ act(() => onError(text, { refusedWhile }))
+}
+
+it('a /clear refused while the agent works is said until the agent stops, then gone', () => {
+ mocks.turnId = 'turn-1'
+ const pane = renderPane()
+ composerSays(STILL_WORKING, 'working')
+ expect(screen.getAllByText(STILL_WORKING)).toHaveLength(1)
+
+ mocks.turnId = null
+ pane.rerender()
+ expect(screen.queryByText(STILL_WORKING)).toBeNull()
+ // Gone, not hidden: the agent working again later is not what that press was refused for.
+ mocks.turnId = 'turn-2'
+ pane.rerender()
+ expect(screen.queryByText(STILL_WORKING)).toBeNull()
+})
+
+it('a /clear refused behind background tasks is said until the last one ends, then gone', () => {
+ mocks.monitoringBackgroundTasks = true
+ const pane = renderPane()
+ composerSays(BACKGROUND, 'background')
+ expect(screen.getAllByText(BACKGROUND)).toHaveLength(1)
+
+ mocks.monitoringBackgroundTasks = false
+ pane.rerender()
+ expect(screen.queryByText(BACKGROUND)).toBeNull()
+})
+
+it('a line naming nothing the chat shows stays until the next send, as before', () => {
+ mocks.turnId = 'turn-1'
+ const pane = renderPane()
+ composerSays('Remove attachments before using a chat-session command.')
+ mocks.turnId = null
+ pane.rerender()
+ expect(screen.getByText('Remove attachments before using a chat-session command.')).toBeTruthy()
+ composerSays(null)
+ expect(screen.queryByText('Remove attachments before using a chat-session command.')).toBeNull()
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx
index e2e762a3ec0e..691e950593cd 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx
@@ -24,6 +24,7 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
const SESSION_ID = 'notice-card-session'
@@ -45,7 +46,7 @@ function renderPane(): void {
)
}
-function composerOnError(): (text: string | null, errorText?: string) => void {
+function composerOnError(): NativeChatStructuredComposerTransport['onError'] {
const onError = mocks.composerProps?.structuredTransport?.onError
if (!onError) {
throw new Error('composer transport has no onError')
@@ -72,7 +73,9 @@ it("shows the chat's own error and a composer error together, where one used to
it('keeps a send failure’s raw error apart and lets the user dismiss it', () => {
renderPane()
act(() => {
- composerOnError()('Your message was not sent.', 'connect ECONNREFUSED /tmp/agent-host.sock')
+ composerOnError()('Your message was not sent.', {
+ errorText: 'connect ECONNREFUSED /tmp/agent-host.sock'
+ })
})
expect(screen.getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE')
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx
index c6589c1e8a4a..cead60b30e21 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx
@@ -10,7 +10,10 @@ import type { AgentSessionRefusalReference } from '../../../../shared/agent-sess
import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard'
import type { NativeChatDeliveryNotice } from './NativeChatMessageRow'
import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard'
-import type { NativeChatLaunchSeed } from './native-chat-composer-types'
+import type {
+ NativeChatLaunchSeed,
+ NativeChatStructuredComposerTransport
+} from './native-chat-composer-types'
import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest'
import type { NativeChatFileLinkContext } from './native-chat-file-link'
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
@@ -22,23 +25,11 @@ import type {
SessionOptionSetResult,
SessionOptionValue
} from '../../../../shared/native-chat-session-options'
+import { absent, nullable, widened } from './native-chat-mock-slot-types.test-support'
type StopBackgroundTaskSpy = (sessionId: string, taskId?: string) => unknown
-function nullable(): T | null {
- return null
-}
-
-function widened(value: T): T {
- return value
-}
-
-function absent(): T | undefined {
- return undefined
-}
-
-/** Stands in for the transcript: renders only each message's delivery notice, or the row's quiet
- * "Sending…" while nothing has confirmed it. */
+/** Stands in for the transcript: renders each message's delivery notice or quiet "Sending…". */
export function DeliveryNoticesMock({
notices
}: {
@@ -142,7 +133,7 @@ export function createStructuredSessionMocks() {
launchSeed?: NativeChatLaunchSeed
structuredTransport?: Record & {
queueResume?: QueueResumeMock
- onError?: (text: string | null, errorText?: string) => void
+ onError?: NativeChatStructuredComposerTransport['onError']
}
isWorking?: boolean
isStopping?: boolean
@@ -242,6 +233,13 @@ export function createStructuredSessionMocks() {
supportsStopAll: mocks.supportsBackgroundTaskStopAll
},
turnId: mocks.turnId,
+ commandRefusalCauses: {
+ working: mocks.turnId !== null || mocks.isWorking,
+ prompt: mocks.promptItems.length > 0,
+ background: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks,
+ sending: false,
+ retry: false
+ },
epoch: 'epoch-1',
rewind: { surface: undefined },
canStop: mocks.canStop ?? mocks.turnId !== null,
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 9da0822c8f92..3149ae3c86a4 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -93,7 +93,9 @@ export function NativeChatStructuredSession(
// phases, that empty list must not become the draft's turn baseline.
transcriptLoading: controller.status === 'idle' || controller.status === 'loading'
})
- const { composerError, reportComposerError } = useNativeChatComposerError()
+ const { composerError, reportComposerError } = useNativeChatComposerError(
+ controller.commandRefusalCauses
+ )
const [optionPickerRequest, setOptionPickerRequest] =
useState(null)
const rootRef = useRef(null)
diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts
index cb73935abfde..a4da39b85fe1 100644
--- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts
+++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts
@@ -37,7 +37,12 @@ const IDS = Object.keys(AGENT_SESSION_FAILURE_COPY).filter(
const NOTICE_PIECES: readonly AgentSessionFailureCopyId[] = [
'terminalAgentHoldsChat',
'quitTerminalAgent',
- 'startNewChat'
+ 'startNewChat',
+ 'backgroundTasksRunning',
+ 'waitForBackgroundTasks',
+ 'agentStarting',
+ 'waitForStart',
+ 'agentStillWorking'
]
// Kana, and kanji whose simplified Chinese form differs (続 is 续, 読 is 读, ...).
const JAPANESE_ONLY = /[\u3040-\u30ff続読変済図気帰戻検択転権単圧応対発処実証覧関専]/u
diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts
index 87c5f88accfe..9aa65486b826 100644
--- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts
+++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts
@@ -182,6 +182,34 @@ const PIECES: Record
+ translate(
+ 'components.native-chat.writeNotice.backgroundTasksRunning',
+ COPY.backgroundTasksRunning
+ ),
+ waitForBackgroundTasks: () =>
+ translate(
+ 'components.native-chat.writeNotice.waitForBackgroundTasks',
+ COPY.waitForBackgroundTasks
+ ),
+ agentStarting: () =>
+ translate('components.native-chat.writeNotice.agentStarting', COPY.agentStarting),
+ waitForStart: () =>
+ translate('components.native-chat.writeNotice.waitForStart', COPY.waitForStart),
+ agentStillWorking: () =>
+ translate('components.native-chat.writeNotice.agentStillWorking', COPY.agentStillWorking),
+ runCommandWhenDone: (values) =>
+ translate(
+ 'components.native-chat.failureWords.runCommandWhenDone',
+ COPY.runCommandWhenDone,
+ values
+ ),
+ commandAfterAnswer: (values) =>
+ translate(
+ 'components.native-chat.failureWords.commandAfterAnswer',
+ COPY.commandAfterAnswer,
+ values
+ ),
compactionFailed: () =>
translate('components.native-chat.failureWords.compactionFailed', COPY.compactionFailed),
compactionFailedQuoted: (values) =>
diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts
index 05bd9e47d238..3589b1084a0b 100644
--- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts
+++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts
@@ -110,6 +110,24 @@ const SENTENCES: Record string> = {
translate('components.native-chat.writeNotice.messagesUnsettled', COPY.messagesUnsettled),
settleEarlierMessage: () =>
translate('components.native-chat.writeNotice.settleEarlierMessage', COPY.settleEarlierMessage),
+ agentStillWorking: () =>
+ translate('components.native-chat.writeNotice.agentStillWorking', COPY.agentStillWorking),
+ runClearWhenDone: () =>
+ translate('components.native-chat.writeNotice.runClearWhenDone', COPY.runClearWhenDone),
+ clearAfterAnswer: () =>
+ translate('components.native-chat.writeNotice.clearAfterAnswer', COPY.clearAfterAnswer),
+ runCompactWhenDone: () =>
+ translate('components.native-chat.writeNotice.runCompactWhenDone', COPY.runCompactWhenDone),
+ compactAfterAnswer: () =>
+ translate('components.native-chat.writeNotice.compactAfterAnswer', COPY.compactAfterAnswer),
+ clearAfterRetry: () =>
+ translate('components.native-chat.writeNotice.clearAfterRetry', COPY.clearAfterRetry),
+ compactAfterRetry: () =>
+ translate('components.native-chat.writeNotice.compactAfterRetry', COPY.compactAfterRetry),
+ clearAfterSending: () =>
+ translate('components.native-chat.writeNotice.clearAfterSending', COPY.clearAfterSending),
+ compactAfterSending: () =>
+ translate('components.native-chat.writeNotice.compactAfterSending', COPY.compactAfterSending),
queueTooLarge: () =>
translate('components.native-chat.writeNotice.queueTooLarge', COPY.queueTooLarge),
shrinkQueue: () => translate('components.native-chat.writeNotice.shrinkQueue', COPY.shrinkQueue),
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts
index 1841e6915105..fae1c3cabcea 100644
--- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts
+++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts
@@ -4,7 +4,10 @@ import type { AgentSessionConversationCommand } from '../../../../shared/agent-s
import type { StructuredAgentContextUsage } from '../../../../shared/structured-agent-session-context-usage'
import type { AgentSessionSlashCommand } from '../../../../shared/agent-session-wire'
import type { AgentType } from '../../../../shared/agent-status-types'
-import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer'
+import type {
+ StructuredAgentSessionCommandOutcome,
+ StructuredAgentSessionCommandRefusalCause
+} from '../../../../shared/structured-agent-session-composer'
import type {
SessionOptionDescriptor,
SessionOptionsSurface
@@ -15,6 +18,13 @@ import type { NativeChatAfterStopSend } from './native-chat-composer-target'
import type { NativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer'
import type { NativeChatRecallSource } from './native-chat-sent-prompt-history'
+export type NativeChatComposerErrorDetail = {
+ /** Error text Orca did not write, shown apart and copyable. */
+ errorText?: string
+ /** A refused command's cause: its line is said only while that still holds. */
+ refusedWhile?: StructuredAgentSessionCommandRefusalCause
+}
+
export type NativeChatOptionPickerRequest = {
id: string
sequence: number
@@ -50,8 +60,7 @@ export type NativeChatStructuredComposerTransport = {
worktreeId?: string
/** Present only where the host can set this session's goal. */
threadGoal?: { setObjective: (objective: string) => Promise }
- /** `errorText` is error text Orca did not write, shown apart and copyable. */
- onError: (message: string | null, errorText?: string) => void
+ onError: (message: string | null, detail?: NativeChatComposerErrorDetail) => void
/** A local send: brings the latest into view at the press, not when the host answers. */
onSubmitted?: () => void
runtime: 'local' | 'remote'
diff --git a/src/renderer/src/components/native-chat/native-chat-mock-slot-types.test-support.ts b/src/renderer/src/components/native-chat/native-chat-mock-slot-types.test-support.ts
new file mode 100644
index 000000000000..4ca845ab45a9
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-mock-slot-types.test-support.ts
@@ -0,0 +1,13 @@
+// Typed starting values for mutable mock slots, so a test can later assign the wider type.
+
+export function nullable(): T | null {
+ return null
+}
+
+export function widened(value: T): T {
+ return value
+}
+
+export function absent(): T | undefined {
+ return undefined
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts
index 7a201ec4e10d..5a8793f928f8 100644
--- a/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts
+++ b/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts
@@ -1,3 +1,4 @@
+import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
@@ -5,11 +6,11 @@ export async function dispatchNativeChatStructuredComposerText(
transport: NativeChatStructuredComposerTransport,
text: string,
attachments: readonly NativeChatComposerImageAttachment[] = []
-): Promise<{ accepted: boolean; error: string | null; revealsTranscript: boolean }> {
- const command = await transport.dispatchCommand(text)
+): Promise & { revealsTranscript: boolean }> {
+ const { handled, ...command } = await transport.dispatchCommand(text)
// A command's reveal, if any, came at the press; a message reveals unless it waits as a queued card.
- if (command.handled) {
- return { accepted: command.accepted, error: command.error, revealsTranscript: false }
+ if (handled) {
+ return { ...command, revealsTranscript: false }
}
const admission = transport.send(text, attachments)
return { accepted: admission !== false, error: null, revealsTranscript: admission === true }
diff --git a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx
index f40d12d87cfd..78d62e8f2e42 100644
--- a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx
+++ b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx
@@ -240,7 +240,7 @@ describe('structured send racing the next IME composition', () => {
changePrompt(input, '/model')
await act(async () => pressEnter(input))
- expect(structured.onError).toHaveBeenCalledWith('nope')
+ expect(structured.onError).toHaveBeenCalledWith('nope', undefined)
expect(structured.send).not.toHaveBeenCalled()
expect(promptValue(input)).toBe('/model')
})
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts b/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts
new file mode 100644
index 000000000000..1a6d849bf18e
--- /dev/null
+++ b/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts
@@ -0,0 +1,46 @@
+import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+import { supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client'
+import {
+ legacyAgentSessionSelectedOptionId,
+ type AgentSessionPromptResponse
+} from '../../../../shared/agent-session-question-answer'
+import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire'
+import type { StructuredPromptItem } from './structured-agent-session-message-projection'
+import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
+
+export async function respondToStructuredAgentSessionPrompt({
+ item,
+ response,
+ target,
+ mutate
+}: {
+ item: StructuredPromptItem
+ response: AgentSessionPromptResponse
+ target: RuntimeClientTarget
+ mutate: StructuredAgentSessionMutate
+}): Promise {
+ const promptTarget = { itemId: item.itemId, expectedRevision: item.revision }
+ let fields: Record
+ if (response.kind === 'option') {
+ fields = { ...promptTarget, optionId: response.optionId }
+ } else if (await supportsStructuredAgentSessionQuestionAnswers(target)) {
+ // Negotiated before mutate fingerprints the call: older hosts reject the strict field.
+ fields = { ...promptTarget, answers: response.answers }
+ } else {
+ const optionId =
+ item.body.kind === 'question'
+ ? legacyAgentSessionSelectedOptionId(item.body, response.answers)
+ : null
+ if (optionId === null) {
+ return null
+ }
+ fields = { ...promptTarget, optionId }
+ }
+ return mutate(
+ item.body.kind === 'approval'
+ ? 'agentSession.respondToApproval'
+ : 'agentSession.respondToQuestion',
+ `agentSession.respondTo:${item.body.kind}`,
+ fields
+ )
+}
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts
index c7efd15b5087..73a3c3ab1bed 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts
@@ -9,6 +9,7 @@ import type { StructuredAgentSessionPendingSend } from './structured-agent-sessi
import {
newestSteerableQueuedMessageCard,
pendingSendsOutsideQueuedCards,
+ pendingQueueSendsOnTheirWay,
projectQueuedMessageCards,
queuedMessageCardSteers,
queuedMessagesQueuePause
@@ -208,6 +209,31 @@ describe('queued message cards', () => {
expect(newestSteerableQueuedMessageCard([])).toBeNull()
})
+ it('marks a command card, which the chord never steers', () => {
+ const compact: AgentSessionQueuedMessage = {
+ ...draft('c', 2),
+ body: {
+ kind: 'message',
+ role: 'user',
+ blocks: [{ type: 'text', text: '/compact' }],
+ command: { name: 'compact' }
+ }
+ }
+ const cards = projectQueuedMessageCards([draft('a', 1), compact], [], IDLE)
+ expect(cards.map((card) => [card.text, card.command ?? false])).toEqual([
+ ['text of a', false],
+ ['/compact', true]
+ ])
+ expect(newestSteerableQueuedMessageCard(cards)).toBeNull()
+ // While the agent works it offers no send; a message card is unaffected.
+ const working = projectQueuedMessageCards([draft('a', 1), compact], [], {
+ ...IDLE,
+ agentWorking: true
+ })
+ expect(working.map((card) => card.waitsForAgent ?? false)).toEqual([false, true])
+ expect(cards[1]).not.toHaveProperty('waitsForAgent')
+ })
+
it('a mid-turn queue send on its way is no bubble; a plain or recorded one is, until its row', () => {
const entry = (
clientMessageId: string,
@@ -243,6 +269,28 @@ describe('queued message cards', () => {
'recorded'
])
})
+ it('a sending card ends once the host records the send or hands its card off', () => {
+ const entry: StructuredAgentSessionPendingSend = {
+ clientMessageId: 'a',
+ sessionId: 'session-1',
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'a' }] },
+ previewUris: [],
+ queuedAt: 1,
+ phase: 'sending',
+ issued: true,
+ delivery: 'queue-if-active'
+ }
+ const onItsWay = (submissions: AgentJournalSubmission[]) =>
+ pendingQueueSendsOnTheirWay([entry], [], true, submissions).map(
+ (candidate) => candidate.clientMessageId
+ )
+ expect(onItsWay([])).toEqual(['a'])
+ expect(onItsWay([submission('a')])).toEqual([])
+ expect(onItsWay([handOff('a')])).toEqual([])
+ expect(pendingQueueSendsOnTheirWay([entry], ['a'], true, [])).toEqual([])
+ expect(pendingQueueSendsOnTheirWay([entry], [], false, [])).toEqual([])
+ expect(pendingQueueSendsOnTheirWay([{ ...entry, phase: 'recorded' }], [], true, [])).toEqual([])
+ })
})
describe("another agent's card", () => {
diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
index e26197360702..4192b552d791 100644
--- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
+++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
@@ -11,6 +11,7 @@ import {
readAgentMessageSource,
type AgentMessageSource
} from '../../../../shared/agent-session-message-source'
+import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off'
/** Why a card is not on its way right now; decides the caption under the text. */
@@ -23,6 +24,9 @@ export type QueuedMessageCardHold =
| 'paused'
| 'behind-returned'
| 'returned'
+ /** On its way to the host, which holds no card for it yet: it reads as sending, and nothing
+ * can act on it until the host's card replaces it under the same id. */
+ | 'sending'
export type QueuedMessageCard = {
messageId: string
@@ -31,6 +35,10 @@ export type QueuedMessageCard = {
text: string
state: 'waiting' | 'returned'
hold: QueuedMessageCardHold
+ /** A conversation command such as /compact: it never steers into a running turn. */
+ command?: true
+ /** A command card while the agent works: it offers no send until the agent is idle. */
+ waitsForAgent?: true
pausedReason?: string
returnedReason?: string | null
/** The typed fact the returned card's submission settled with; read like its `rejection`. */
@@ -53,7 +61,7 @@ function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string
export function projectQueuedMessageCards(
queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined,
submissions: readonly AgentJournalSubmission[],
- session: { hasPendingPrompt: boolean; queuePaused?: boolean }
+ session: { hasPendingPrompt: boolean; queuePaused?: boolean; agentWorking?: boolean }
): QueuedMessageCard[] {
const handedOff = handedOffQueuedMessageIds(
submissions.filter((submission) => submission.dispatchState !== 'rejected')
@@ -83,6 +91,12 @@ export function projectQueuedMessageCards(
text: queuedMessageCardText(message.body),
state: message.state,
hold,
+ ...(message.body.command !== undefined
+ ? {
+ command: true as const,
+ ...(session.agentWorking ? { waitsForAgent: true as const } : {})
+ }
+ : {}),
...(message.pausedReason !== undefined ? { pausedReason: message.pausedReason } : {}),
...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}),
...(message.returnedRejection !== undefined
@@ -93,6 +107,17 @@ export function projectQueuedMessageCards(
})
}
+/** A command card waits in line: a later send goes behind it, even with follow-ups off. A card
+ * held on its own (kept, couldn't send) is skipped by the queue, so nothing is behind it. */
+export function commandCardWaiting(
+ queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined
+): boolean {
+ return (queuedMessages ?? []).some(
+ (message) =>
+ message.state === 'waiting' && !message.paused && message.body.command !== undefined
+ )
+}
+
/** The pause the header row names, while it holds a card. A pause over cards Resume would not
* send (returned, held on their own, or behind a returned one) offers nothing to press. */
export function queuedMessagesQueuePause(
@@ -103,16 +128,31 @@ export function queuedMessagesQueuePause(
}
/** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own or
- * returned is not waiting on the turn, so its action is plainly Send. */
+ * returned is not waiting on the turn, so its action is plainly Send. A command never steers. */
export function queuedMessageCardSteers(card: QueuedMessageCard): boolean {
- return card.hold !== 'paused' && card.hold !== 'returned'
+ return card.hold !== 'paused' && card.hold !== 'returned' && !card.command
}
-/** The card Cmd/Ctrl+Enter steers: the newest one; every shown card takes Send-now. */
+/** The card Cmd/Ctrl+Enter steers: the newest one, unless it is a command, which never steers. */
export function newestSteerableQueuedMessageCard(
cards: readonly QueuedMessageCard[]
): QueuedMessageCard | null {
- return cards.at(-1) ?? null
+ const newest = cards.at(-1)
+ return newest && !newest.command && newest.hold !== 'sending' ? newest : null
+}
+
+/** A queue send still on its way, as the card it is about to become. */
+export function sendingQueuedMessageCards(
+ entries: readonly StructuredAgentSessionPendingSend[]
+): QueuedMessageCard[] {
+ return entries.map((entry, index) => ({
+ messageId: entry.clientMessageId,
+ // After every card the host holds, in send order.
+ position: Number.MAX_SAFE_INTEGER - entries.length + index,
+ text: queuedMessageCardText(entry.body),
+ state: 'waiting',
+ hold: 'sending'
+ }))
}
/**
@@ -130,3 +170,26 @@ export function pendingSendsOutsideQueuedCards<
)
return next.length === pending.length ? pending : next
}
+
+/** Queue sends without a host card or submission yet, drawn as sending cards. */
+export function pendingQueueSendsOnTheirWay(
+ pending: readonly StructuredAgentSessionPendingSend[],
+ heldIds: readonly string[],
+ isWorking: boolean,
+ submissions: readonly AgentJournalSubmission[]
+): StructuredAgentSessionPendingSend[] {
+ if (!isWorking) {
+ return []
+ }
+ const recorded = new Set([
+ ...heldIds,
+ ...handedOffQueuedMessageIds(submissions),
+ ...submissions.map((submission) => submission.clientMessageId)
+ ])
+ return pending.filter(
+ (entry) =>
+ entry.phase === 'sending' &&
+ entry.delivery === 'queue-if-active' &&
+ !recorded.has(entry.clientMessageId)
+ )
+}
diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
index a972b660063b..c765001325a4 100644
--- a/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
+++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.test.ts
@@ -1,11 +1,23 @@
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { i18n } from '@/i18n/i18n'
import type { AgentSessionConversationCommandResult } from '../../../../shared/agent-session-conversation-command'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
-import { sendStructuredConversationCommand } from './structured-conversation-command-send'
+import {
+ sendStructuredConversationCommand,
+ structuredConversationCommandHold,
+ type StructuredConversationCommandCauses
+} from './structured-conversation-command-send'
+
+const NO_CAUSES: StructuredConversationCommandCauses = {
+ working: false,
+ prompt: false,
+ background: false,
+ sending: false,
+ retry: false
+}
const START_FAILED: AgentSessionFailureFact = { kind: 'startFailed' }
const COMPACTION_FAILED: AgentSessionFailureFact = {
@@ -59,7 +71,8 @@ async function sent(
command: result.command,
agentName: structuredAgentLabel(provider),
pending: { current: false },
- blocked: false,
+ hold: null,
+ causes: NO_CAUSES,
startFailures: () => [],
send: async () => ({ kind: 'done', value: result })
})
@@ -154,7 +167,8 @@ describe('the line under the composer after a conversation command failed', () =
command: 'compact',
agentName: 'Claude',
pending: { current: false },
- blocked: false,
+ hold: null,
+ causes: NO_CAUSES,
startFailures: () => [START_FAILED],
send: async () => ({ kind: 'done', value: result })
})
@@ -176,3 +190,193 @@ describe('the line under the composer after a conversation command failed', () =
})
})
})
+
+describe('a /compact the host holds in line', () => {
+ it('reads its queued answer as sent, with no line under the composer', async () => {
+ expect(
+ await sent({
+ command: 'compact',
+ state: 'completed',
+ queued: { messageId: 'op-1', position: 1, state: 'waiting' }
+ })
+ ).toEqual({ accepted: true, error: null })
+ })
+
+ const idle = {
+ waitsInLine: false,
+ agentWorking: false,
+ promptPending: false,
+ backgroundTasksRunning: false,
+ sendPending: false
+ }
+
+ it('is held here only by a message the host does not have yet, or background work', () => {
+ const inLine = { ...idle, waitsInLine: true }
+ expect(
+ structuredConversationCommandHold({
+ ...inLine,
+ agentWorking: true,
+ promptPending: true
+ })
+ ).toBeNull()
+ expect(structuredConversationCommandHold({ ...inLine, sendPending: true })).toBe('ahead')
+ expect(structuredConversationCommandHold({ ...inLine, backgroundTasksRunning: true })).toBe(
+ 'background'
+ )
+ })
+
+ it('against a host that cannot hold it, and for /clear, keeps every check in true words', () => {
+ expect(structuredConversationCommandHold(idle)).toBeNull()
+ expect(structuredConversationCommandHold({ ...idle, agentWorking: true })).toBe('working')
+ // The agent idle, only this window's own message still on its way: it is still being sent.
+ expect(structuredConversationCommandHold({ ...idle, sendPending: true })).toBe('sending')
+ expect(
+ structuredConversationCommandHold({ ...idle, agentWorking: true, sendPending: true })
+ ).toBe('working')
+ expect(structuredConversationCommandHold({ ...idle, promptPending: true })).toBe('prompt')
+ })
+})
+
+function held(
+ command: 'clear' | 'compact',
+ hold: Parameters[0]['hold'],
+ pending = { current: false },
+ causes = NO_CAUSES
+) {
+ const send = vi.fn(async () => ({
+ kind: 'done' as const,
+ value: { command, state: 'completed' as const }
+ }))
+ return {
+ send,
+ result: sendStructuredConversationCommand({
+ command,
+ agentName: structuredAgentLabel('claude'),
+ pending,
+ hold,
+ causes,
+ startFailures: () => [],
+ send
+ })
+ }
+}
+
+describe('a command held here', () => {
+ it('behind a message still on its way: not taken, no line, nothing left to go out later', async () => {
+ const { send, result } = held('compact', 'ahead')
+ expect(await result).toEqual({ accepted: false, error: null })
+ expect(send).not.toHaveBeenCalled()
+ })
+
+ it('a second command while one is on its way says the agent is working', async () => {
+ const { send, result } = held('clear', null, { current: true })
+ expect(await result).toEqual({
+ accepted: false,
+ error: "The agent is still working. Run /clear when it's done."
+ })
+ expect(send).not.toHaveBeenCalled()
+ })
+
+ it('behind a message still being sent after a Stop, says so, naming no Retry', async () => {
+ expect(await held('clear', 'sending').result).toEqual({
+ accepted: false,
+ error: 'Your earlier message is still being sent. Run /clear once it has gone.'
+ })
+ })
+
+ it('a /clear while the agent works says so in plain words, once', async () => {
+ expect(await held('clear', 'working').result).toEqual({
+ accepted: false,
+ error: "The agent is still working. Run /clear when it's done."
+ })
+ expect(await held('clear', 'prompt').result).toEqual({
+ accepted: false,
+ error: "Answer the agent's question or approval, then run /clear."
+ })
+ })
+
+ it('a /compact a host without the queue refuses reads the same way', async () => {
+ expect(await held('compact', 'working').result).toEqual({
+ accepted: false,
+ error: "The agent is still working. Run /compact when it's done."
+ })
+ expect(await held('compact', 'prompt').result).toEqual({
+ accepted: false,
+ error: "Answer the agent's question or approval, then run /compact."
+ })
+ })
+})
+
+describe('a refusal names what it waits on only while the chat shows it', () => {
+ const shown = (cause: keyof StructuredConversationCommandCauses) => ({
+ ...NO_CAUSES,
+ [cause]: true
+ })
+ const refusedBy = (
+ reason: 'turnActive' | 'messagesUnsettled' | 'promptPending' | 'backgroundTasksRunning'
+ ): AgentSessionConversationCommandResult =>
+ hostResult('clear', {
+ kind: 'commandRefused',
+ refusal: { code: 'agent_session_operation_invalid', details: { reason } }
+ })
+ const hostAnswers = (
+ result: AgentSessionConversationCommandResult,
+ causes: StructuredConversationCommandCauses
+ ) =>
+ sendStructuredConversationCommand({
+ command: result.command,
+ agentName: structuredAgentLabel('claude'),
+ pending: { current: false },
+ hold: null,
+ causes,
+ startFailures: () => [],
+ send: async () => ({ kind: 'done', value: result })
+ })
+
+ it('held here: the agent working, a prompt, background tasks, or a message not yet gone', async () => {
+ const pending = { current: false }
+ expect((await held('clear', 'working', pending, shown('working')).result).refusedWhile).toBe(
+ 'working'
+ )
+ expect((await held('clear', 'prompt', pending, shown('prompt')).result).refusedWhile).toBe(
+ 'prompt'
+ )
+ expect(
+ (await held('compact', 'background', pending, shown('background')).result).refusedWhile
+ ).toBe('background')
+ expect((await held('clear', 'sending', pending, shown('sending')).result).refusedWhile).toBe(
+ 'sending'
+ )
+ })
+
+ it('names none the chat does not show, so the line cannot go before it is read', async () => {
+ // A rewind on its way holds the command in background words, with no strip to watch.
+ const rewinding = await held('compact', 'background').result
+ expect(rewinding.error).toBeTruthy()
+ expect(rewinding).not.toHaveProperty('refusedWhile')
+ // A command on its way is not something the chat shows ending.
+ const second = await held('clear', null, { current: true }, shown('working')).result
+ expect(second).not.toHaveProperty('refusedWhile')
+ })
+
+ it('from the host: the reason its words name, when the chat shows it', async () => {
+ for (const reason of ['turnActive', 'messagesUnsettled'] as const) {
+ expect((await hostAnswers(refusedBy(reason), shown('working'))).refusedWhile).toBe('working')
+ }
+ expect((await hostAnswers(refusedBy('promptPending'), shown('prompt'))).refusedWhile).toBe(
+ 'prompt'
+ )
+ const background = await hostAnswers(refusedBy('backgroundTasksRunning'), shown('background'))
+ expect(background).toEqual({
+ accepted: false,
+ error:
+ 'Background tasks are still running. Wait for the background tasks to finish. Run /clear again.',
+ refusedWhile: 'background'
+ })
+ // Ahead of the chat, or a reason that names nothing it shows: said as any failure.
+ expect(await hostAnswers(refusedBy('turnActive'), NO_CAUSES)).not.toHaveProperty('refusedWhile')
+ expect(
+ await hostAnswers(hostResult('compact', COMPACTION_FAILED), shown('working'))
+ ).not.toHaveProperty('refusedWhile')
+ })
+})
diff --git a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
index 0a351db917f1..47ae05dcbc6d 100644
--- a/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
+++ b/src/renderer/src/components/native-chat/structured-conversation-command-send.ts
@@ -10,29 +10,133 @@ import {
import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words'
import { translate } from '@/i18n/i18n'
import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
-import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices'
+import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
+import {
+ agentSessionFailureStatedByStartRow,
+ structuredAgentSessionStartFailureFacts
+} from './structured-agent-session-delivery-notices'
+import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject'
+import type { StructuredPromptItem } from './structured-agent-session-message-projection'
+import type {
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from '../../../../shared/agent-session-journal-types'
+import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
+import { structuredAgentSessionCommandHostRefusalCause } from '../../../../shared/structured-agent-session-command-refusal-cause'
+import type {
+ StructuredAgentSessionCommandOutcome,
+ StructuredAgentSessionCommandRefusalCause
+} from '../../../../shared/structured-agent-session-composer'
import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate'
+/** Why this client does not send the command yet. `ahead`: a message this window sent is not
+ * the host's yet; the command is not taken, and its text stays in the composer while Send is
+ * busy, as for any send on its way. The rest are refused here, in words. */
+export type StructuredConversationCommandHold =
+ | 'ahead'
+ | 'working'
+ | 'prompt'
+ | 'background'
+ | 'sending'
+
+/** A command that waits in line is held only by a message the host doesn't have yet, which must
+ * stay ahead of it; any other command, by anything the agent still has in flight. */
+export function structuredConversationCommandHold(input: {
+ /** A /compact the host can hold as a card behind the turn or prompt. */
+ waitsInLine: boolean
+ /** A turn runs, or the chat shows the agent working on a message it has not answered. */
+ agentWorking: boolean
+ promptPending: boolean
+ backgroundTasksRunning: boolean
+ /** This chat's one message request has not settled yet. */
+ sendPending: boolean
+}): StructuredConversationCommandHold | null {
+ if (input.backgroundTasksRunning) {
+ return 'background'
+ }
+ if (input.waitsInLine) {
+ return input.sendPending ? 'ahead' : null
+ }
+ if (input.promptPending) {
+ return 'prompt'
+ }
+ if (input.agentWorking) {
+ return 'working'
+ }
+ // The agent is idle and this window's own message hasn't reached the host: that is the wait.
+ if (input.sendPending) {
+ return 'sending'
+ }
+ return null
+}
+
+type CommandOutcome = Omit
+
+/** Which of what a refused command waits on the chat shows right now. */
+export type StructuredConversationCommandCauses = Readonly<
+ Record
+>
+
+/** The line a command refused here gets: what the person sees and can do, as the host says it. */
+function heldCommandText(
+ command: AgentSessionConversationCommand,
+ hold: Exclude
+): string {
+ const clear = command === 'clear'
+ switch (hold) {
+ case 'prompt':
+ return agentSessionWriteNoticeText([clear ? 'clearAfterAnswer' : 'compactAfterAnswer'])
+ case 'working':
+ return agentSessionWriteNoticeText([
+ 'agentStillWorking',
+ clear ? 'runClearWhenDone' : 'runCompactWhenDone'
+ ])
+ case 'sending':
+ return agentSessionWriteNoticeText([clear ? 'clearAfterSending' : 'compactAfterSending'])
+ case 'background':
+ break
+ }
+ return translate(
+ 'components.native-chat.conversationCommand.pendingWork',
+ 'Wait for pending work and messages to finish before using this command.'
+ )
+}
+
export async function sendStructuredConversationCommand(input: {
command: AgentSessionConversationCommand
/** The chat's agent, as a failed command names it. */
agentName: string
pending: { current: boolean }
- blocked: boolean
+ hold: StructuredConversationCommandHold | null
+ /** What the chat showed at the press. A refusal names its cause only when the chat showed it,
+ * so a host ahead of the chat can't make the line go the moment it lands; else it is said as
+ * any other failure. */
+ causes: StructuredConversationCommandCauses
/** What the chat's loaded start-failure rows state, read when the reply lands. */
startFailures: () => readonly AgentSessionFailureFact[]
send: (
command: AgentSessionConversationCommand
) => Promise>
-}): Promise<{ accepted: boolean; error: string | null }> {
- if (input.pending.current || input.blocked) {
- return {
- accepted: false,
- error: translate(
- 'components.native-chat.conversationCommand.pendingWork',
- 'Wait for pending work and messages to finish before using this command.'
- )
- }
+}): Promise {
+ const refused = (
+ error: string | null,
+ cause: StructuredAgentSessionCommandRefusalCause | undefined
+ ): CommandOutcome => ({
+ accepted: false,
+ error,
+ ...(error && cause && input.causes[cause] ? { refusedWhile: cause } : {})
+ })
+ // A command on its way is the agent's work in flight.
+ if (input.pending.current) {
+ return { accepted: false, error: heldCommandText(input.command, 'working') }
+ }
+ // The message ahead reads as sending, and Send is busy until the host has it; nothing is armed.
+ if (input.hold === 'ahead') {
+ return { accepted: false, error: null }
+ }
+ if (input.hold !== null) {
+ // Each hold here is named for the cause it waits on.
+ return refused(heldCommandText(input.command, input.hold), input.hold)
}
input.pending.current = true
try {
@@ -56,12 +160,88 @@ export async function sendStructuredConversationCommand(input: {
return { accepted: false, error: null }
}
const error = conversationCommandFailureText(value, input.agentName)
- return { accepted: value.state === 'completed' && !error, error }
+ if (value.state === 'completed' && !error) {
+ return { accepted: true, error: null }
+ }
+ return refused(error, structuredAgentSessionCommandHostRefusalCause(value))
} finally {
input.pending.current = false
}
}
+/** The composer's /clear or /compact, with what this pane knows about work in flight; and which
+ * of what a refusal waits on the chat shows, which its line stands on. */
+export function structuredConversationCommandRunner(args: {
+ agentName: string
+ pending: { current: boolean }
+ /** The host holds a /compact as a card, and this client renders the queue. */
+ commandsWait: boolean
+ /** What the chat shows: a turn, the Working rule, its background work, its sends. */
+ chat: {
+ turnId: string | null
+ isWorking: boolean
+ /** The queue's send is the host's next update: the pane reads working. */
+ queueSendsNext: boolean
+ backgroundTasks: { isMonitoring: boolean; show: boolean }
+ submissions: readonly AgentJournalSubmission[]
+ }
+ /** The chat's pending prompts: a card would wait forever on ones this build cannot answer. */
+ prompts: readonly StructuredPromptItem[]
+ /** A rewind this pane started is on its way; read at the press. */
+ rewindInFlight: { readonly current: boolean }
+ sends: readonly StructuredAgentSessionPendingSend[]
+ /** The loaded rows, read when the reply lands, for the start failures they state. */
+ items: () => readonly AgentJournalRenderItem[]
+ send: (
+ command: AgentSessionConversationCommand,
+ delivery?: 'queue-if-active'
+ ) => Promise>
+}): {
+ runConversationCommand: (command: AgentSessionConversationCommand) => Promise
+ /** What a refusal's line stands on, as the chat shows it now. */
+ commandRefusalCauses: StructuredConversationCommandCauses
+} {
+ const promptPending = args.prompts.length > 0
+ const agentWorking = args.chat.turnId !== null || args.chat.isWorking || args.chat.queueSendsNext
+ const sendPending = args.sends.some((entry) => entry.phase === 'sending')
+ const causes: StructuredConversationCommandCauses = {
+ working: agentWorking,
+ prompt: promptPending,
+ // The strip, mid-turn included: what a host refusing on background tasks points at.
+ background: args.chat.backgroundTasks.show || args.chat.backgroundTasks.isMonitoring,
+ sending: sendPending,
+ retry: false
+ }
+ const run = (command: AgentSessionConversationCommand) => {
+ const waitsInLine =
+ command === 'compact' &&
+ args.commandsWait &&
+ !(promptPending && pendingPromptsAllUnanswerableHere(args.prompts))
+ return sendStructuredConversationCommand({
+ command,
+ agentName: args.agentName,
+ pending: args.pending,
+ hold: structuredConversationCommandHold({
+ waitsInLine,
+ agentWorking,
+ promptPending,
+ // A rewind on its way holds a command as background work does, in the same words.
+ backgroundTasksRunning:
+ args.chat.backgroundTasks.isMonitoring || args.rewindInFlight.current,
+ sendPending
+ }),
+ causes,
+ startFailures: () => structuredAgentSessionStartFailureFacts(args.items()),
+ send: (command) =>
+ args.send(
+ command,
+ command === 'compact' && args.commandsWait ? 'queue-if-active' : undefined
+ )
+ })
+ }
+ return { runConversationCommand: run, commandRefusalCauses: causes }
+}
+
/** The host's sentence in the reader's language, from the fact beside it; with no fact (an older
* host), one this build can't read whole, or a command it doesn't know, the sentence as written. */
function conversationCommandFailureText(
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts
index 68935493bdc0..5c488ab8cbcf 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts
@@ -3,6 +3,14 @@ import type {
NativeChatComposerNotice,
NativeChatComposerNoticeContent
} from './native-chat-composer-notice'
+import type {
+ NativeChatComposerErrorDetail,
+ NativeChatStructuredComposerTransport
+} from './native-chat-composer-types'
+import type { StructuredConversationCommandCauses } from './structured-conversation-command-send'
+
+type ComposerErrorLine = NativeChatComposerNoticeContent &
+ Pick
/** The composer's own paste and attachment notice, after the chat's notices. */
export function useNativeChatComposerNotice(chatNotices?: readonly NativeChatComposerNotice[]): {
@@ -35,20 +43,40 @@ export function useNativeChatComposerNotice(chatNotices?: readonly NativeChatCom
}
/** A chat's last send or command error: shown in the card until dismissed or the next send clears
- * it. */
-export function useNativeChatComposerError(): {
+ * it. A command's refusal names what the chat shows it waiting on, so it goes once that is gone. */
+export function useNativeChatComposerError(causes: StructuredConversationCommandCauses): {
composerError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null
- reportComposerError: (text: string | null, errorText?: string) => void
+ reportComposerError: NativeChatStructuredComposerTransport['onError']
} {
- const [content, setContent] = useState(null)
- const reportComposerError = useCallback(
- (text: string | null, errorText?: string) =>
- setContent(text === null ? null : { text, ...(errorText ? { errorText } : {}) }),
+ const [line, setLine] = useState(null)
+ const reportComposerError = useCallback(
+ (text, detail) =>
+ setLine(
+ text === null
+ ? null
+ : {
+ text,
+ ...(detail?.errorText ? { errorText: detail.errorText } : {}),
+ ...(detail?.refusedWhile ? { refusedWhile: detail.refusedWhile } : {})
+ }
+ ),
[]
)
+ const shown = line !== null && (!line.refusedWhile || causes[line.refusedWhile]) ? line : null
+ // Dropped, not hidden: the cause coming back later is not what this refusal was about.
+ if (line !== null && shown === null) {
+ setLine(null)
+ }
const composerError = useMemo(
- () => (content ? { ...content, onDismiss: () => setContent(null) } : null),
- [content]
+ () =>
+ shown
+ ? {
+ text: shown.text,
+ ...(shown.errorText ? { errorText: shown.errorText } : {}),
+ onDismiss: () => setLine(null)
+ }
+ : null,
+ [shown]
)
return { composerError, reportComposerError }
}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
index 18fc826a30a0..f1615691bdbf 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
@@ -137,7 +137,8 @@ describe('reports the sends that bring the latest into view', () => {
send('/permissions', [])
await vi.waitFor(() =>
expect(structuredTransport.onError).toHaveBeenCalledWith(
- expect.stringContaining('not available in chat sessions')
+ expect.stringContaining('not available in chat sessions'),
+ undefined
)
)
expect(onSubmitted).not.toHaveBeenCalled()
@@ -154,10 +155,9 @@ describe("says a failed send in Orca's words, with the error it hit apart", () =
})
send('hello', [])
await vi.waitFor(() =>
- expect(structuredTransport.onError).toHaveBeenCalledWith(
- 'Your message was not sent.',
- 'connect ECONNREFUSED /tmp/a.sock'
- )
+ expect(structuredTransport.onError).toHaveBeenCalledWith('Your message was not sent.', {
+ errorText: 'connect ECONNREFUSED /tmp/a.sock'
+ })
)
})
})
diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts
index b52e0483d46b..1f0ce4e27b93 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts
@@ -92,8 +92,8 @@ export function useNativeChatStructuredComposerSend({
}
const submitted = sentFrom ?? readNativeChatComposerDraft(draftScopeKey)
await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments)
- .then(({ accepted, error, revealsTranscript }) => {
- structuredTransport.onError(error)
+ .then(({ accepted, error, refusedWhile, revealsTranscript }) => {
+ structuredTransport.onError(error, refusedWhile ? { refusedWhile } : undefined)
if (!accepted) {
return
}
@@ -129,7 +129,7 @@ export function useNativeChatStructuredComposerSend({
error,
agentSessionWriteNoticeText([hostCommand ? 'notDoneCommand' : 'notDoneSend'])
)
- structuredTransport.onError(notice.text, notice.errorText)
+ structuredTransport.onError(notice.text, { errorText: notice.errorText })
})
},
[
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx
index 9e504d5ae5b7..1326b1f16a06 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx
@@ -1,13 +1,14 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
-import { beforeEach, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
outboxSend: vi.fn()
}))
let items: AgentJournalRenderItem[] = []
+let backgroundTasks: AgentSessionBackgroundTaskState | undefined
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
@@ -22,7 +23,8 @@ vi.mock('./use-structured-agent-session-read', () => ({
status: 'ready',
error: null,
hasOlder: false,
- handoff: null
+ handoff: null,
+ ...(backgroundTasks ? { backgroundTasks } : {})
},
loadingOlder: false,
loadOlder: vi.fn()
@@ -42,6 +44,7 @@ vi.mock('./use-structured-agent-session-sends', () => ({
}))
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-background-task-wire'
import { useStructuredAgentSession } from './use-structured-agent-session'
function answer(text: string, scoped: boolean): AgentJournalRenderItem {
@@ -58,6 +61,7 @@ function answer(text: string, scoped: boolean): AgentJournalRenderItem {
beforeEach(() => {
vi.clearAllMocks()
mocks.outboxSend.mockReturnValue(true)
+ backgroundTasks = undefined
})
/** Starts `/compact` and leaves its reply outstanding, then sends a message. */
@@ -92,3 +96,59 @@ it('queues a message typed during a command on a host that runs it as a turn', (
expect(sendDuringCommand()).toBe(true)
expect(mocks.outboxSend).toHaveBeenCalledOnce()
})
+
+// What a refused /clear names goes from the chat with its cause, so its line can go too.
+describe('a /clear refused for something the chat shows', () => {
+ const renderSession = () =>
+ renderHook(() =>
+ useStructuredAgentSession({
+ sessionId: 'session-1',
+ agent: 'codex',
+ target: { kind: 'local' },
+ isVisible: true
+ })
+ )
+ async function clear(result: { current: ReturnType }) {
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.runConversationCommand('clear')
+ })
+ return outcome
+ }
+
+ it('while the agent works: named working, which ends when the agent stops', async () => {
+ items = [
+ {
+ itemId: 'turn-1',
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: { kind: 'turn', turnId: 'provider-turn', state: 'running' }
+ }
+ ]
+ const { result, rerender } = renderSession()
+ expect(await clear(result)).toEqual({
+ accepted: false,
+ error: "The agent is still working. Run /clear when it's done.",
+ refusedWhile: 'working'
+ })
+ expect(result.current.commandRefusalCauses.working).toBe(true)
+ items = []
+ rerender()
+ expect(result.current.commandRefusalCauses.working).toBe(false)
+ expect(
+ mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.conversationCommand')
+ ).toHaveLength(0)
+ })
+
+ it('while background tasks run: named background, which ends with the last task', async () => {
+ items = []
+ backgroundTasks = { state: 'monitoring', tasks: [{ id: 'task-1', kind: 'command' }] }
+ const { result, rerender } = renderSession()
+ expect(await clear(result)).toMatchObject({ accepted: false, refusedWhile: 'background' })
+ expect(result.current.commandRefusalCauses.background).toBe(true)
+ backgroundTasks = undefined
+ rerender()
+ expect(result.current.commandRefusalCauses.background).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts
index 36d798414a89..54b0df881b3f 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-write.ts
@@ -20,7 +20,8 @@ export function useStructuredAgentSessionCommandWrite(
sessionId: string,
write: StructuredAgentSessionWrite
): (
- command: AgentSessionConversationCommand
+ command: AgentSessionConversationCommand,
+ delivery?: 'queue-if-active'
) => Promise> {
// Null once the view left or unmounted, so a reply that lands later releases its hold.
const shown = useRef(sessionId)
@@ -34,12 +35,12 @@ export function useStructuredAgentSessionCommandWrite(
}
}, [sessionId])
return useCallback(
- async (command) => {
+ async (command, delivery) => {
const send = () =>
write(
'agentSession.conversationCommand',
'agentSession.conversationCommand',
- { command }
+ { command, ...(delivery ? { delivery } : {}) }
)
if (command !== 'clear') {
return send()
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
index dfb5a211c5c8..b56d5b0eb916 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts
@@ -17,11 +17,14 @@ import type {
AgentSessionSendResult
} from '../../../../shared/agent-session-wire'
import { appendNativeChatDraftCache } from './native-chat-draft-cache'
+import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store'
import {
newestSteerableQueuedMessageCard,
projectQueuedMessageCards,
+ pendingQueueSendsOnTheirWay,
queuedMessagesQueuePause,
+ sendingQueuedMessageCards,
type QueuedMessageCard
} from './structured-agent-session-queued-cards'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
@@ -61,6 +64,8 @@ export type StructuredAgentSessionQueueResume = {
resuming: boolean
}
+const NO_SENDS: readonly StructuredAgentSessionPendingSend[] = []
+
function alreadySentNotice(): void {
toast.error(
translate('components.native-chat.queuedMessages.alreadySent', 'This message was already sent.')
@@ -76,19 +81,33 @@ export function useStructuredAgentSessionQueuedMessages(args: {
hasPendingPrompt: boolean
/** A turn is running, whoever started it, or the queue is about to send its next card. */
isWorking: boolean
+ /** This pane's sends without a host record yet show as sending cards. */
+ sending?: readonly StructuredAgentSessionPendingSend[]
composerScopeKey: string | undefined
mutate: StructuredAgentSessionMutate
}): StructuredAgentSessionQueuedMessagesController {
const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args
- const { queuePause } = args
+ const { isWorking, queuePause } = args
+ const sending = args.sending ?? NO_SENDS
const cards = useMemo(
- () =>
- projectQueuedMessageCards(queuedMessages, submissions, {
+ () => [
+ ...projectQueuedMessageCards(queuedMessages, submissions, {
hasPendingPrompt,
+ // A command card offers no send while the agent works.
+ agentWorking: isWorking,
queuePaused: queuePause !== null
}),
- [hasPendingPrompt, queuePause, queuedMessages, submissions]
+ ...sendingQueuedMessageCards(
+ pendingQueueSendsOnTheirWay(
+ sending,
+ (queuedMessages ?? []).map((message) => message.messageId),
+ isWorking,
+ submissions
+ )
+ )
+ ],
+ [hasPendingPrompt, isWorking, sending, queuePause, queuedMessages, submissions]
)
const cardsRef = useRef(cards)
useEffect(() => {
@@ -157,7 +176,7 @@ export function useStructuredAgentSessionQueuedMessages(args: {
// never a wire payload. Without a composer to hold it, deleting would destroy it,
// so the draft then stays a card.
const card = cardsRef.current.find((entry) => entry.messageId === messageId)
- if (!card || !composerScopeKey) {
+ if (!card || card.command || !composerScopeKey) {
return
}
appendNativeChatDraftCache(composerScopeKey, card.text)
@@ -219,7 +238,7 @@ export function useStructuredAgentSessionQueuedMessages(args: {
// Resume and the "Send message?" choice only where the queue could send now: no turn runs (the
// queue's coming send counts, as the host names it) and no prompt waits, which holds the queue
// too; the composer shows beside a prompt only when this build cannot answer it.
- const held = enabled && pause !== null && !args.isWorking && !hasPendingPrompt
+ const held = enabled && pause !== null && !isWorking && !hasPendingPrompt
const queueResume = useMemo(
() => (held ? { resume, resuming } : undefined),
[held, resume, resuming]
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
index b6ef99e9e4f1..96a7b3c8d2a4 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
@@ -256,8 +256,7 @@ describe('a chat write the host refused', () => {
await expect(result.current.cancel('turn-1')).resolves.toBeNull()
await expect(result.current.runConversationCommand('compact')).resolves.toEqual({
accepted: false,
- error:
- "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it."
+ error: "The agent is still working. Run /compact when it's done."
})
})
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
index dae635600e69..52736ea98470 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
@@ -9,19 +9,27 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+import type {
+ AgentJournalRenderItem,
+ AgentJournalSubmission
+} from '../../../../shared/agent-session-journal-types'
import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire'
import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
+import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
+import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices'
+import type * as RewindModule from './use-native-chat-rewind'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
- outboxArgs: Array.of<{ queue?: { capability: string; enabled: boolean } }>(),
- operations: 0
+ sendArgs: Array.of<{ queue?: { capability: string; enabled: boolean } }>(),
+ operations: 0,
+ rewindPending: false
}))
let items: AgentJournalRenderItem[] = []
let queuedMessages: AgentSessionQueuedMessage[] | undefined
+let submissions: AgentJournalSubmission[] = []
let nextQueuedMessageId: string | null = null
-let outboxEntries: StructuredAgentSessionPendingSend[] = []
+let pendingSends: StructuredAgentSessionPendingSend[] = []
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call,
@@ -33,7 +41,7 @@ vi.mock('./use-structured-agent-session-read', () => ({
state: {
fence: 3,
items,
- submissions: [],
+ submissions,
status: 'ready',
error: null,
hasOlder: false,
@@ -44,11 +52,25 @@ vi.mock('./use-structured-agent-session-read', () => ({
})
}))
+// The real rewind hook, with only its in-flight latch forced when a test says so.
+vi.mock('./use-native-chat-rewind', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useStructuredAgentSessionRewind: (
+ ...args: Parameters
+ ) => {
+ const rewind = actual.useStructuredAgentSessionRewind(...args)
+ return mocks.rewindPending ? { ...rewind, blockedRef: { current: true } } : rewind
+ }
+ }
+})
+
vi.mock('./use-structured-agent-session-sends', () => ({
useStructuredAgentSessionSends: (args: { queue?: { capability: string; enabled: boolean } }) => {
- mocks.outboxArgs.push(args)
+ mocks.sendArgs.push(args)
return {
- pending: outboxEntries,
+ pending: pendingSends,
error: null,
send: vi.fn(),
stopSends: vi.fn()
@@ -58,6 +80,7 @@ vi.mock('./use-structured-agent-session-sends', () => ({
import {
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
@@ -130,7 +153,8 @@ function cancels(): unknown[] {
beforeEach(() => {
vi.clearAllMocks()
- mocks.outboxArgs.length = 0
+ mocks.sendArgs.length = 0
+ mocks.rewindPending = false
mocks.call.mockImplementation(async (_target, method) =>
method === 'agentSession.cancel'
? {
@@ -144,8 +168,9 @@ beforeEach(() => {
)
items = [RUNNING_TURN]
queuedMessages = undefined
+ submissions = []
nextQueuedMessageId = null
- outboxEntries = []
+ pendingSends = []
localStorage.clear()
clearNativeChatDraftCacheForTests()
})
@@ -164,13 +189,13 @@ describe('against a capable host', () => {
it('queues sends while the setting is on, immediately when it is off', () => {
render()
- expect(mocks.outboxArgs.at(-1)?.queue).toEqual({
+ expect(mocks.sendArgs.at(-1)?.queue).toEqual({
capability: 'supported',
enabled: true
})
- mocks.outboxArgs.length = 0
+ mocks.sendArgs.length = 0
render(false)
- expect(mocks.outboxArgs.at(-1)?.queue).toEqual({
+ expect(mocks.sendArgs.at(-1)?.queue).toEqual({
capability: 'supported',
enabled: false
})
@@ -202,14 +227,14 @@ describe('against a capable host', () => {
const newer = newerApproval()
items = [newer]
render()
- expect(mocks.outboxArgs.at(-1)?.queue).toEqual({
+ expect(mocks.sendArgs.at(-1)?.queue).toEqual({
capability: 'supported',
enabled: false
})
- mocks.outboxArgs.length = 0
+ mocks.sendArgs.length = 0
items = [newer, approval({ kind: 'plan', text: 'do it' })]
render()
- expect(mocks.outboxArgs.at(-1)?.queue).toEqual({
+ expect(mocks.sendArgs.at(-1)?.queue).toEqual({
capability: 'supported',
enabled: true
})
@@ -310,7 +335,7 @@ describe('against a capable host', () => {
issued: true,
...(delivery ? { delivery } : {})
})
- outboxEntries = [
+ pendingSends = [
// On its way asking to be queued: its card, not a bubble, shows it.
entry('pending-queue', 'awaiting the answer', 'queue-if-active'),
// Sent plain: a bubble.
@@ -338,10 +363,13 @@ describe('against a capable host', () => {
const { result } = render()
expect(result.current).toMatchObject({ isWorking: true, queueSendsNext: true, canStop: false })
expect(result.current.queuedMessages.cards.map((card) => card.hold)).toEqual(['turn'])
+ // A command refused now is said while the pane reads working, and no longer.
+ expect(result.current.commandRefusalCauses.working).toBe(true)
// Where the host would refuse that send, it names none: the chat reads idle.
nextQueuedMessageId = null
const refused = render()
expect(refused.result.current).toMatchObject({ isWorking: false, queueSendsNext: false })
+ expect(refused.result.current.commandRefusalCauses.working).toBe(false)
})
it('shows host-held drafts as cards, never as transcript bubbles', () => {
@@ -353,6 +381,358 @@ describe('against a capable host', () => {
})
})
+function commandCalls(): unknown[] {
+ return mocks.call.mock.calls
+ .filter(([, method]) => method === 'agentSession.conversationCommand')
+ .map(([, , params]) => params)
+}
+
+function answerCommands(value: Record): void {
+ mocks.call.mockImplementation(async (_target, method) =>
+ method === 'agentSession.conversationCommand'
+ ? { ok: true, replayed: false, fence: 3, cursor: { epoch: 'e', sequence: 1 }, value }
+ : method === 'agentSession.cancel'
+ ? {
+ ok: true,
+ replayed: false,
+ fence: 3,
+ cursor: { epoch: 'e', sequence: 1 },
+ value: { cancelled: true }
+ }
+ : null
+ )
+}
+
+describe('a /compact against a host that holds commands in line', () => {
+ beforeEach(() => {
+ setLocalRuntimeCapabilitiesForTests([
+ AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY
+ ])
+ })
+
+ it('mid-turn, goes to the host asking to wait, and its queued answer shows no notice', async () => {
+ answerCommands({
+ command: 'compact',
+ state: 'completed',
+ queued: { messageId: 'operation-1', position: 1, state: 'waiting' }
+ })
+ const { result } = render()
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.runConversationCommand('compact')
+ })
+ expect(outcome).toEqual({ accepted: true, error: null })
+ const parsed = ConversationCommandParams.parse(commandCalls()[0])
+ expect(parsed).toMatchObject({ command: 'compact', delivery: 'queue-if-active' })
+ expect(parsed.envelope.payloadFingerprint).toBe(
+ structuredAgentSessionPayloadFingerprint({
+ method: 'agentSession.conversationCommand',
+ sessionId: 'session-1',
+ fields: { command: 'compact', delivery: 'queue-if-active' }
+ })
+ )
+ })
+
+ function unsent(
+ id: string,
+ overrides: Partial = {}
+ ): StructuredAgentSessionPendingSend {
+ return {
+ clientMessageId: id,
+ sessionId: 'session-1',
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `message ${id}` }] },
+ previewUris: [],
+ queuedAt: 1,
+ phase: 'sending',
+ issued: true,
+ ...overrides
+ }
+ }
+
+ it('behind a message still on its way: Send is busy, the message reads Sending, nothing is armed', async () => {
+ items = []
+ answerCommands({ command: 'compact', state: 'completed' })
+ pendingSends = [unsent('on-its-way')]
+ const { result, rerender } = render()
+ // The composer's send control is Stop while a send is on its way: the existing busy state.
+ expect(result.current.canStop).toBe(true)
+ // Its row reads "Sending…" (`NativeChatMessageRow`), from the same outbox.
+ expect(
+ structuredAgentSessionDeliveryNotices({
+ pending: pendingSends,
+ agentName: 'Claude',
+ submissions: [],
+ startFailures: []
+ }).get(agentJournalSubmissionKey('on-its-way'))
+ ).toEqual({ sending: true })
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.runConversationCommand('compact')
+ })
+ expect(outcome).toEqual({ accepted: false, error: null })
+ // The host has it now: nothing goes out on its own; the next press does.
+ pendingSends = []
+ rerender()
+ expect(commandCalls()).toHaveLength(0)
+ expect(result.current.canStop).toBe(false)
+ await act(async () => {
+ outcome = await result.current.runConversationCommand('compact')
+ })
+ expect(outcome).toEqual({ accepted: true, error: null })
+ expect(commandCalls()).toHaveLength(1)
+ })
+
+ it('Stop settles the pending send and leaves no command armed behind it', async () => {
+ items = []
+ answerCommands({ command: 'compact', state: 'completed' })
+ pendingSends = [unsent('on-its-way')]
+ const { result, rerender } = render()
+ await act(async () => {
+ expect(await result.current.runConversationCommand('compact')).toEqual({
+ accepted: false,
+ error: null
+ })
+ await result.current.stop()
+ })
+ pendingSends = []
+ rerender()
+ expect(result.current.canStop).toBe(false)
+ expect(commandCalls()).toHaveLength(0)
+ await act(async () => {
+ expect(await result.current.runConversationCommand('compact')).toEqual({
+ accepted: true,
+ error: null
+ })
+ })
+ })
+
+ it('mid-turn, behind a queue send on its way: it reads Sending as a card, and nothing is armed', async () => {
+ answerCommands({ command: 'compact', state: 'completed' })
+ pendingSends = [
+ unsent('on-its-way', {
+ delivery: 'queue-if-active'
+ })
+ ]
+ const { result } = render()
+ // Not a transcript bubble mid-turn; the card it is about to become reads as sending.
+ expect(JSON.stringify(result.current.messages)).not.toContain('message on-its-way')
+ expect(result.current.queuedMessages.cards).toEqual([
+ expect.objectContaining({
+ messageId: 'on-its-way',
+ text: 'message on-its-way',
+ hold: 'sending'
+ })
+ ])
+ await act(async () => {
+ expect(await result.current.runConversationCommand('compact')).toEqual({
+ accepted: false,
+ error: null
+ })
+ })
+ expect(commandCalls()).toHaveLength(0)
+ })
+
+ it('an idle send the host has recorded is its transcript row only, never a sending card too', () => {
+ items = []
+ pendingSends = [
+ unsent('recorded', {
+ delivery: 'queue-if-active'
+ })
+ ]
+ // The host took it straight through: its own submission, unanswered, makes the chat working.
+ submissions = [
+ {
+ clientMessageId: 'recorded',
+ fence: 3,
+ payloadFingerprint: 'fingerprint',
+ dispatchState: 'pending',
+ providerItemId: null,
+ reason: null,
+ submittedAt: 2,
+ resolvedAt: null,
+ handoverRecorded: true,
+ handedOverAt: 3
+ }
+ ]
+ const { result } = render()
+ expect(result.current.isWorking).toBe(true)
+ expect(result.current.queuedMessages.cards).toEqual([])
+ })
+
+ it('/clear right after a Stop kept a send on its way names no Retry it does not show', async () => {
+ items = []
+ pendingSends = [
+ unsent('kept', {
+ delivery: 'queue-if-active'
+ })
+ ]
+ const { result } = render()
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.runConversationCommand('clear')
+ })
+ expect(outcome).toEqual({
+ accepted: false,
+ error: 'Your earlier message is still being sent. Run /clear once it has gone.',
+ refusedWhile: 'sending'
+ })
+ })
+
+ it('/clear with the agent idle behind its own unsent message says it is still being sent', async () => {
+ items = []
+ pendingSends = [unsent('on-its-way')]
+ const { result, rerender } = render()
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.runConversationCommand('clear')
+ })
+ expect(outcome).toEqual({
+ accepted: false,
+ error: 'Your earlier message is still being sent. Run /clear once it has gone.',
+ refusedWhile: 'sending'
+ })
+ expect(commandCalls()).toHaveLength(0)
+ // A failed send returns to the composer and no longer blocks another command.
+ pendingSends = []
+ rerender()
+ expect(result.current.commandRefusalCauses).toMatchObject({ sending: false, retry: false })
+ })
+
+ it('a recorded message no longer blocks /clear or /compact', async () => {
+ items = []
+ pendingSends = [unsent('recorded', { phase: 'recorded' })]
+ const { result } = render()
+ for (const command of ['clear', 'compact'] as const) {
+ answerCommands({ command, state: 'completed' })
+ await act(async () => {
+ expect(await result.current.runConversationCommand(command)).toEqual({
+ accepted: true,
+ error: null
+ })
+ })
+ }
+ expect(commandCalls()).toHaveLength(2)
+ expect(result.current.commandRefusalCauses).toMatchObject({ sending: false, retry: false })
+ })
+
+ it('/clear mid-turn is still refused here, and never asks to wait', async () => {
+ const { result } = render()
+ await act(async () => {
+ await result.current.runConversationCommand('clear')
+ })
+ expect(commandCalls()).toHaveLength(0)
+ items = []
+ answerCommands({ command: 'clear', state: 'completed' })
+ const idle = render()
+ await act(async () => {
+ await idle.result.current.runConversationCommand('clear')
+ })
+ expect(ConversationCommandParams.parse(commandCalls()[0])).not.toHaveProperty('delivery')
+ })
+
+ it('a rewind on its way holds /clear and /compact alike, in the same words, and writes nothing', async () => {
+ items = []
+ mocks.rewindPending = true
+ const { result } = render()
+ for (const command of ['clear', 'compact'] as const) {
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.runConversationCommand(command)
+ })
+ expect(outcome).toEqual({
+ accepted: false,
+ error: 'Wait for pending work and messages to finish before using this command.'
+ })
+ }
+ expect(commandCalls()).toHaveLength(0)
+ })
+
+ const compactCard = (held: boolean) => ({
+ ...draft('compact-1'),
+ body: {
+ kind: 'message' as const,
+ role: 'user' as const,
+ blocks: [{ type: 'text' as const, text: '/compact' }],
+ command: { name: 'compact' as const }
+ },
+ ...(held ? { paused: true as const, pausedReason: 'send_failed' as const } : {})
+ })
+
+ it('a send behind a waiting command card queues, even with follow-ups off', () => {
+ queuedMessages = [compactCard(false)]
+ render(false)
+ expect(mocks.sendArgs.at(-1)?.queue).toEqual({
+ capability: 'supported',
+ enabled: true
+ })
+ })
+
+ it('a send-failed command card, which the queue skips, does not force a send to queue', () => {
+ queuedMessages = [compactCard(true)]
+ render(false)
+ expect(mocks.sendArgs.at(-1)?.queue).toEqual({
+ capability: 'supported',
+ enabled: false
+ })
+ })
+})
+
+describe('a /compact against a host that queues messages but not commands', () => {
+ beforeEach(() => {
+ setLocalRuntimeCapabilitiesForTests([
+ AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY
+ ])
+ })
+
+ it('mid-turn, is held back here as today, and idle goes out without `delivery`', async () => {
+ const { result } = render()
+ await act(async () => {
+ await result.current.runConversationCommand('compact')
+ })
+ expect(commandCalls()).toHaveLength(0)
+ items = []
+ answerCommands({ command: 'compact', state: 'completed' })
+ const idle = render()
+ await act(async () => {
+ await idle.result.current.runConversationCommand('compact')
+ })
+ expect(ConversationCommandParams.parse(commandCalls()[0])).not.toHaveProperty('delivery')
+ })
+})
+
+describe('a /compact against a host that holds commands but has its queue dark', () => {
+ beforeEach(() => {
+ setLocalRuntimeCapabilitiesForTests([
+ AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY
+ ])
+ })
+
+ it("is exactly today's: held back mid-turn, and idle goes out without `delivery`", async () => {
+ const { result } = render()
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.runConversationCommand('compact')
+ })
+ expect(outcome).toEqual({
+ accepted: false,
+ error: "The agent is still working. Run /compact when it's done.",
+ refusedWhile: 'working'
+ })
+ expect(commandCalls()).toHaveLength(0)
+ items = []
+ answerCommands({ command: 'compact', state: 'completed' })
+ const idle = render()
+ await act(async () => {
+ await idle.result.current.runConversationCommand('compact')
+ })
+ expect(ConversationCommandParams.parse(commandCalls()[0])).not.toHaveProperty('delivery')
+ })
+})
+
describe('against a host without the capability', () => {
beforeEach(() => {
setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY])
@@ -360,7 +740,7 @@ describe('against a host without the capability', () => {
it('never asks for queue delivery, whatever the setting says', () => {
render()
- expect(mocks.outboxArgs.at(-1)?.queue?.capability).toBe('unsupported')
+ expect(mocks.sendArgs.at(-1)?.queue?.capability).toBe('unsupported')
})
it("Stop stays exactly today's conversation Stop — no withdrawQueued key at all", async () => {
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
index 062de2b6356c..d21cb5b29a97 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts
@@ -1,24 +1,19 @@
import { useMemo, useRef } from 'react'
-import * as structuredConversationCommands from './structured-conversation-command-send'
-import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire'
import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends'
import { useStructuredAgentSessionCommandWrite } from './use-structured-agent-session-command-write'
import { structuredAgentSessionNewSendsQueue } from './structured-agent-session-queue-request'
-import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import { takeBackStructuredLaunchPrompts } from '@/lib/structured-agent-session-launch-prompt'
+import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client'
import {
- supportsStructuredAgentSessionPromptCancel,
- supportsStructuredAgentSessionQuestionAnswers
-} from '@/runtime/structured-agent-session-client'
-import { useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability'
+ useStructuredAgentSessionHostQueuesCommands,
+ useStructuredAgentSessionHostQueuesMessagesState
+} from '@/runtime/structured-agent-session-host-capability'
import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control'
-import {
- legacyAgentSessionSelectedOptionId,
- type AgentSessionPromptResponse
-} from '../../../../shared/agent-session-question-answer'
+import type { AgentSessionPromptResponse } from '../../../../shared/agent-session-question-answer'
+import { respondToStructuredAgentSessionPrompt } from './structured-agent-session-prompt-response'
import {
pendingStructuredSessionPrompts,
type StructuredPromptItem
@@ -33,8 +28,11 @@ import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-sess
import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage'
import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline'
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
-import { pendingSendsOutsideQueuedCards } from './structured-agent-session-queued-cards'
-import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices'
+import { structuredConversationCommandRunner } from './structured-conversation-command-send'
+import {
+ commandCardWaiting,
+ pendingSendsOutsideQueuedCards
+} from './structured-agent-session-queued-cards'
import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership'
import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject'
import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice'
@@ -130,9 +128,12 @@ export function useStructuredAgentSession(args: {
mutate
})
const prompts = pendingStructuredSessionPrompts(transportState.journalItems)
+ const promptsUnanswerableHere = pendingPromptsAllUnanswerableHere(prompts)
+ // A send after a command the queue will run goes behind it, even with follow-ups off.
// A host's queue waits on any pending prompt, and nothing here can settle one this build cannot
// answer: the send must start a turn, after which the card's cancel works.
- const queueEnabled = queueFollowUps && !pendingPromptsAllUnanswerableHere(prompts)
+ const commandWaiting = commandCardWaiting(transportState.queuedMessages)
+ const queueEnabled = (queueFollowUps || commandWaiting) && !promptsUnanswerableHere
const queue = useMemo(
() => ({ capability: queueCapability, enabled: queueEnabled }),
[queueCapability, queueEnabled]
@@ -169,9 +170,8 @@ export function useStructuredAgentSession(args: {
const { pending } = sends
const commandWrite = useStructuredAgentSessionCommandWrite(sessionId, write)
- // Out and unsettled: the chat takes no other send, and Stop stops it.
const sending = pending.some((entry) => entry.phase === 'sending')
- // What the host refuses a conversation command or a rewind behind.
+ // What the host refuses a rewind behind; a command's own hold is the runner's below.
const conversationBusy = Boolean(
transportState.turnId ||
prompts.length ||
@@ -214,6 +214,8 @@ export function useStructuredAgentSession(args: {
enabled: queueCapability === 'supported' && transportState.fence !== null,
hasPendingPrompt: prompts.length > 0,
isWorking,
+ // Hidden from the transcript, a queue send on its way reads as sending among the cards.
+ sending: pending,
composerScopeKey,
mutate
})
@@ -221,15 +223,19 @@ export function useStructuredAgentSession(args: {
epoch: state.epoch,
rewind,
conversationCommands,
- runConversationCommand: (command: AgentSessionConversationCommand) =>
- structuredConversationCommands.sendStructuredConversationCommand({
- command,
- agentName: structuredAgentLabel(agent),
- pending: commandPending,
- blocked: conversationBusy || rewind.blockedRef.current,
- startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items),
- send: commandWrite
- }),
+ ...structuredConversationCommandRunner({
+ agentName: structuredAgentLabel(agent),
+ pending: commandPending,
+ // A /compact waits in line only where its card renders.
+ commandsWait:
+ useStructuredAgentSessionHostQueuesCommands(target) && queueCapability === 'supported',
+ chat: transportState,
+ prompts,
+ rewindInFlight: rewind.blockedRef,
+ sends: pending,
+ items: () => stateRef.current.items,
+ send: commandWrite
+ }),
journalItems: transcriptItems,
/** The host's newest turn record, which places a live turn whose record is not loaded. */
latestTurn: transportState.latestTurn,
@@ -296,32 +302,8 @@ export function useStructuredAgentSession(args: {
scope: 'background-tasks',
...(taskId ? { taskId } : {})
}),
- respond: async (item: StructuredPromptItem, response: AgentSessionPromptResponse) => {
- const promptTarget = { itemId: item.itemId, expectedRevision: item.revision }
- let fields: Record
- if (response.kind === 'option') {
- fields = { ...promptTarget, optionId: response.optionId }
- } else if (await supportsStructuredAgentSessionQuestionAnswers(target)) {
- // Negotiated before mutate fingerprints the call: older hosts reject the strict field.
- fields = { ...promptTarget, answers: response.answers }
- } else {
- const optionId =
- item.body.kind === 'question'
- ? legacyAgentSessionSelectedOptionId(item.body, response.answers)
- : null
- if (optionId === null) {
- return null
- }
- fields = { ...promptTarget, optionId }
- }
- return mutate(
- item.body.kind === 'approval'
- ? 'agentSession.respondToApproval'
- : 'agentSession.respondToQuestion',
- `agentSession.respondTo:${item.body.kind}`,
- fields
- )
- },
+ respond: (item: StructuredPromptItem, response: AgentSessionPromptResponse) =>
+ respondToStructuredAgentSessionPrompt({ item, response, target, mutate }),
optionSnapshot,
optionSurface,
sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined,
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index f47ca0c96a2b..e399a57ee8dc 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -2879,6 +2879,7 @@
"chooseClaudeAccount": "Choose or add one in Claude Accounts settings.",
"chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.",
"chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.",
+ "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}.",
"claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.",
"claudeSystemNotSignedIn": "Claude isn't signed in. Run `claude` and sign in with /login, or choose an account in Claude Accounts settings.",
"cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.",
@@ -2920,6 +2921,7 @@
"providerStartFailed": "{{agent}} stopped before it finished starting.",
"queueFull": "Too many messages were waiting for the agent, so this one was not sent.",
"runCommandAgain": "Run /{{command}} again.",
+ "runCommandWhenDone": "Run /{{command}} when it's done.",
"sendAgainToTryOnceMore": "Send your message again to try once more.",
"sendToTryAgain": "Send your message to try again.",
"sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.",
@@ -3013,6 +3015,7 @@
"writeNotice": {
"agentRefused": "The agent turned this down.",
"agentStarting": "The agent is still starting.",
+ "agentStillWorking": "The agent is still working.",
"answerFirst": "Answer the question or approval first.",
"attachmentExpired": "This attachment expired.",
"backgroundTasksRunning": "Background tasks are still running.",
@@ -3020,8 +3023,14 @@
"capacity": "Orca on the computer running this chat has hit a request limit. Update Orca there, then try again.",
"chatNotFound": "Orca can't find this chat.",
"chatSavedByNewerOrca": "This chat was saved by a newer Orca.",
+ "clearAfterAnswer": "Answer the agent's question or approval, then run /clear.",
+ "clearAfterRetry": "Retry your earlier message, then run /clear.",
+ "clearAfterSending": "Your earlier message is still being sent. Run /clear once it has gone.",
"clearUnfinished": "The last /clear didn't finish.",
"commandRunning": "A /compact or /clear is still running.",
+ "compactAfterAnswer": "Answer the agent's question or approval, then run /compact.",
+ "compactAfterRetry": "Retry your earlier message, then run /compact.",
+ "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone.",
"conversationCleared": "This conversation has been cleared.",
"goalsUnsupported": "This agent doesn't support goals.",
"historyUnavailable": "Orca couldn't open this chat's history right now.",
@@ -3053,6 +3062,8 @@
"recordUnreadable": "Orca couldn't read this chat's saved state.",
"reopenChat": "Reopen the chat to check again.",
"restartFailed": "The agent couldn't restart.",
+ "runClearWhenDone": "Run /clear when it's done.",
+ "runCompactWhenDone": "Run /compact when it's done.",
"savedByNewerOrca": "Chats were saved by a newer Orca.",
"sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.",
"settleEarlierMessage": "Wait for your earlier message to go through, or retry it.",
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index d56b5ad2b560..c31c67c6dde0 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18067,7 +18067,9 @@
"argumentsMissingValue": "Saved Arguments need a value for {{option}}.",
"argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.",
"argumentsPositionalPrompt": "Saved Arguments include a prompt.",
- "editSavedArguments": "Edit them in Settings > Agents > Arguments."
+ "editSavedArguments": "Edit them in Settings > Agents > Arguments.",
+ "runCommandWhenDone": "Run /{{command}} when it's done.",
+ "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}."
},
"writeNotice": {
"notDoneReadHistory": "This chat's history couldn't be loaded.",
@@ -18129,7 +18131,16 @@
"recordUnreadable": "Orca couldn't read this chat's saved state.",
"chatNotFound": "Orca can't find this chat.",
"startNewChat": "Start a new chat to continue.",
- "tryAgain": "Try again."
+ "tryAgain": "Try again.",
+ "agentStillWorking": "The agent is still working.",
+ "runClearWhenDone": "Run /clear when it's done.",
+ "clearAfterAnswer": "Answer the agent's question or approval, then run /clear.",
+ "runCompactWhenDone": "Run /compact when it's done.",
+ "compactAfterAnswer": "Answer the agent's question or approval, then run /compact.",
+ "clearAfterRetry": "Retry your earlier message, then run /clear.",
+ "compactAfterRetry": "Retry your earlier message, then run /compact.",
+ "clearAfterSending": "Your earlier message is still being sent. Run /clear once it has gone.",
+ "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone."
},
"taskList": {
"title": "Tasks",
@@ -18619,7 +18630,8 @@
"sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?",
"sendConfirmClear": "Clear queue",
"sendConfirmSend": "Send message",
- "from": "From {{names}}"
+ "from": "From {{names}}",
+ "pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes."
},
"thought": "Thought",
"thoughtForDuration": "Thought for {{duration}}",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index a914e04dbc26..3c421443e5ac 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -17845,6 +17845,8 @@
"providerRetryNumberOf": "Reintento {{attempt}} de {{maxRetries}}.",
"providerRetryLastError": "Último error: {{detail}}.",
"previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}.",
+ "runCommandWhenDone": "Ejecute /{{command}} cuando termine.",
+ "commandAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /{{command}}.",
"sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores."
},
"writeNotice": {
@@ -17907,7 +17909,16 @@
"recordUnreadable": "Orca no pudo leer el estado guardado de este chat.",
"chatNotFound": "Orca no encuentra este chat.",
"startNewChat": "Inicie un chat nuevo para continuar.",
- "tryAgain": "Vuelva a intentarlo."
+ "tryAgain": "Vuelva a intentarlo.",
+ "agentStillWorking": "El agente todavía está trabajando.",
+ "runClearWhenDone": "Ejecute /clear cuando termine.",
+ "clearAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /clear.",
+ "runCompactWhenDone": "Ejecute /compact cuando termine.",
+ "compactAfterAnswer": "Responda a la pregunta o aprobación del agente y luego ejecute /compact.",
+ "clearAfterRetry": "Reintente su mensaje anterior y luego ejecute /clear.",
+ "compactAfterRetry": "Reintente su mensaje anterior y luego ejecute /compact.",
+ "clearAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /clear cuando se haya enviado.",
+ "compactAfterSending": "Su mensaje anterior todavía se está enviando. Ejecute /compact cuando se haya enviado."
},
"composer": {
"imageUnsupported": "Este agente no admite pegar imágenes.",
@@ -18230,7 +18241,8 @@
"sendConfirmBody_other": "Estás a punto de enviar un mensaje. ¿Quieres borrar los {{count}} mensajes que había en cola?",
"sendConfirmClear": "Vaciar cola",
"sendConfirmSend": "Enviar mensaje",
- "from": "De {{names}}"
+ "from": "De {{names}}",
+ "pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar."
},
"thought": "Pensó",
"thoughtForDuration": "Pensó durante {{duration}}",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 7bf8eb683a59..2e61dcc2320a 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -17946,6 +17946,8 @@
"providerRetryNumberOf": "Nouvelle tentative {{attempt}} sur {{maxRetries}}.",
"providerRetryLastError": "Dernière erreur : {{detail}}.",
"previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}.",
+ "runCommandWhenDone": "Lancez /{{command}} quand il aura terminé.",
+ "commandAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /{{command}}.",
"sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents."
},
"writeNotice": {
@@ -18008,7 +18010,16 @@
"recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.",
"chatNotFound": "Orca ne trouve pas ce chat.",
"startNewChat": "Démarrez un nouveau chat pour continuer.",
- "tryAgain": "Réessayez."
+ "tryAgain": "Réessayez.",
+ "agentStillWorking": "L'agent est encore en train de travailler.",
+ "runClearWhenDone": "Lancez /clear quand il aura terminé.",
+ "clearAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /clear.",
+ "runCompactWhenDone": "Lancez /compact quand il aura terminé.",
+ "compactAfterAnswer": "Répondez à la question ou à la demande d'approbation de l'agent, puis lancez /compact.",
+ "clearAfterRetry": "Réessayez votre message précédent, puis lancez /clear.",
+ "compactAfterRetry": "Réessayez votre message précédent, puis lancez /compact.",
+ "clearAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /clear une fois qu'il est parti.",
+ "compactAfterSending": "Votre message précédent est encore en cours d'envoi. Lancez /compact une fois qu'il est parti."
},
"composer": {
"imageUnsupported": "Le collage d'image n'est pas pris en charge pour cet agent.",
@@ -18467,7 +18478,8 @@
"sendConfirmBody_other": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer les {{count}} messages déjà en file d'attente ?",
"sendConfirmClear": "Vider la file d'attente",
"sendConfirmSend": "Envoyer le message",
- "from": "De {{names}}"
+ "from": "De {{names}}",
+ "pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer."
},
"thought": "A réfléchi",
"thoughtForDuration": "A réfléchi pendant {{duration}}",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 5096ff9c7f65..4fd314a04a49 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -17880,6 +17880,8 @@
"providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。",
"providerRetryLastError": "直近のエラー: {{detail}}。",
"previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。",
+ "runCommandWhenDone": "終わったら /{{command}} を実行してください。",
+ "commandAfterAnswer": "Agent の質問または承認に回答してから、/{{command}} を実行してください。",
"sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。"
},
"writeNotice": {
@@ -17942,7 +17944,16 @@
"recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。",
"chatNotFound": "Orca はこのチャットを見つけられません。",
"startNewChat": "続けるには、新しいチャットを開始してください。",
- "tryAgain": "もう一度お試しください。"
+ "tryAgain": "もう一度お試しください。",
+ "agentStillWorking": "Agent はまだ作業中です。",
+ "runClearWhenDone": "終わったら /clear を実行してください。",
+ "clearAfterAnswer": "Agent の質問または承認に回答してから、/clear を実行してください。",
+ "runCompactWhenDone": "終わったら /compact を実行してください。",
+ "compactAfterAnswer": "Agent の質問または承認に回答してから、/compact を実行してください。",
+ "clearAfterRetry": "以前のメッセージを再試行してから、/clear を実行してください。",
+ "compactAfterRetry": "以前のメッセージを再試行してから、/compact を実行してください。",
+ "clearAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /clear を実行してください。",
+ "compactAfterSending": "以前のメッセージはまだ送信中です。送信が終わったら /compact を実行してください。"
},
"composer": {
"imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。",
@@ -18401,7 +18412,8 @@
"sendConfirmBody_other": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?",
"sendConfirmClear": "キューを消去",
"sendConfirmSend": "メッセージを送信",
- "from": "送信元: {{names}}"
+ "from": "送信元: {{names}}",
+ "pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。"
},
"thought": "考えました",
"thoughtForDuration": "{{duration}} 考えました",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 3b8312655bea..85b2081b1865 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -17880,6 +17880,8 @@
"providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.",
"providerRetryLastError": "마지막 오류: {{detail}}.",
"previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다.",
+ "runCommandWhenDone": "작업이 끝나면 /{{command}}을(를) 실행하세요.",
+ "commandAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /{{command}}을(를) 실행하세요.",
"sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다."
},
"writeNotice": {
@@ -17942,7 +17944,16 @@
"recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.",
"chatNotFound": "Orca에서 이 채팅을 찾을 수 없습니다.",
"startNewChat": "계속하려면 새 채팅을 시작하세요.",
- "tryAgain": "다시 시도하세요."
+ "tryAgain": "다시 시도하세요.",
+ "agentStillWorking": "에이전트가 아직 작업 중입니다.",
+ "runClearWhenDone": "작업이 끝나면 /clear를 실행하세요.",
+ "clearAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /clear를 실행하세요.",
+ "runCompactWhenDone": "작업이 끝나면 /compact를 실행하세요.",
+ "compactAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /compact를 실행하세요.",
+ "clearAfterRetry": "이전 메시지를 다시 시도한 다음 /clear를 실행하세요.",
+ "compactAfterRetry": "이전 메시지를 다시 시도한 다음 /compact를 실행하세요.",
+ "clearAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /clear를 실행하세요.",
+ "compactAfterSending": "이전 메시지를 아직 보내는 중입니다. 전송이 끝나면 /compact를 실행하세요."
},
"composer": {
"imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.",
@@ -18401,7 +18412,8 @@
"sendConfirmBody_other": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?",
"sendConfirmClear": "대기열 지우기",
"sendConfirmSend": "메시지 보내기",
- "from": "보낸 사람: {{names}}"
+ "from": "보낸 사람: {{names}}",
+ "pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요."
},
"thought": "생각함",
"thoughtForDuration": "{{duration}} 동안 생각함",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 4df85264d1d8..e3a3d0905363 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -17845,6 +17845,8 @@
"providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。",
"providerRetryLastError": "上次错误:{{detail}}。",
"previousExitUnverifiable": "无法停止之前的 {{agent}}。",
+ "runCommandWhenDone": "完成后再运行 /{{command}}。",
+ "commandAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /{{command}}。",
"sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。"
},
"writeNotice": {
@@ -17907,7 +17909,16 @@
"recordUnreadable": "Orca 无法读取此聊天的已保存状态。",
"chatNotFound": "Orca 找不到此聊天。",
"startNewChat": "请开始新的聊天以继续。",
- "tryAgain": "请重试。"
+ "tryAgain": "请重试。",
+ "agentStillWorking": "智能体仍在工作。",
+ "runClearWhenDone": "完成后再运行 /clear。",
+ "clearAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /clear。",
+ "runCompactWhenDone": "完成后再运行 /compact。",
+ "compactAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /compact。",
+ "clearAfterRetry": "请先重试您之前的消息,然后再运行 /clear。",
+ "compactAfterRetry": "请先重试您之前的消息,然后再运行 /compact。",
+ "clearAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /clear。",
+ "compactAfterSending": "您之前的消息仍在发送中。发送完成后再运行 /compact。"
},
"composer": {
"imageUnsupported": "此智能体不支持粘贴图片。",
@@ -18366,7 +18377,8 @@
"sendConfirmBody_other": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?",
"sendConfirmClear": "清空队列",
"sendConfirmSend": "发送消息",
- "from": "来自 {{names}}"
+ "from": "来自 {{names}}",
+ "pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。"
},
"thought": "已思考",
"thoughtForDuration": "思考了 {{duration}}",
diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts
index 0f91cb82586f..cf1d8c4baa22 100644
--- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts
+++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import {
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_REWIND_RECOVERY_CAPABILITY,
type RuntimeCapability
@@ -117,3 +118,12 @@ export function useStructuredAgentSessionHostQueuesMessagesState(
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY
)
}
+
+/** Whether the host holds a /compact sent while the agent works as a queued card: only then may
+ * a client send the command's `delivery`, and only where it also renders the queue's cards. */
+export function useStructuredAgentSessionHostQueuesCommands(target: RuntimeClientTarget): boolean {
+ return useStructuredAgentSessionHostCapability(
+ target,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY
+ )
+}
diff --git a/src/shared/agent-session-command-refusal-words.ts b/src/shared/agent-session-command-refusal-words.ts
new file mode 100644
index 000000000000..ec85fe3b9947
--- /dev/null
+++ b/src/shared/agent-session-command-refusal-words.ts
@@ -0,0 +1,49 @@
+// A conversation command refused for a reason the person can act on: said as that refusal is
+// everywhere, so a command's row names what to wait for rather than "try it again".
+
+import type { AgentSessionFailureFact } from './agent-session-failure'
+import type { AgentSessionFailureSay } from './agent-session-failure-copy'
+import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
+import { joinSentences } from './sentence-joining'
+
+type ReasonWords = (
+ say: AgentSessionFailureSay,
+ command: AgentSessionConversationCommand | undefined
+) => string | undefined
+
+const STILL_WORKING: ReasonWords = (say, command) =>
+ command
+ ? joinSentences([say('agentStillWorking'), say('runCommandWhenDone', { command })])
+ : undefined
+
+/** A wait the person ends: nothing runs the command after it, so they run it again. */
+const WAIT_THEN_RUN_AGAIN =
+ (
+ cause: 'backgroundTasksRunning' | 'agentStarting',
+ wait: 'waitForBackgroundTasks' | 'waitForStart'
+ ): ReasonWords =>
+ (say, command) =>
+ joinSentences([
+ say(cause),
+ say(wait),
+ ...(command ? [say('runCommandAgain', { command })] : [])
+ ])
+
+const WORDS_BY_REASON: Partial> = {
+ backgroundTasksRunning: WAIT_THEN_RUN_AGAIN('backgroundTasksRunning', 'waitForBackgroundTasks'),
+ handoffInFlight: WAIT_THEN_RUN_AGAIN('agentStarting', 'waitForStart'),
+ turnActive: STILL_WORKING,
+ messagesUnsettled: STILL_WORKING,
+ promptPending: (say, command) => (command ? say('commandAfterAnswer', { command }) : undefined)
+}
+
+/** Undefined for a reason with no words of its own: the command's generic sentence says it. */
+export function commandRefusedByReason(
+ say: AgentSessionFailureSay,
+ { command }: { command?: AgentSessionConversationCommand },
+ fact: AgentSessionFailureFact
+): string | undefined {
+ const details = fact.refusal?.details
+ const reason = details && 'reason' in details ? details.reason : undefined
+ return reason === undefined ? undefined : WORDS_BY_REASON[reason]?.(say, command)
+}
diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts
index d1e5a2671fad..b696d0515317 100644
--- a/src/shared/agent-session-conversation-command.ts
+++ b/src/shared/agent-session-conversation-command.ts
@@ -1,5 +1,6 @@
import type { AgentSessionFailureFact } from './agent-session-failure'
import type { AgentSessionFailureSentence } from './agent-session-failure-words'
+import type { AgentSessionQueuedSendReceipt } from './agent-session-wire'
export type AgentSessionConversationCommand = 'clear' | 'compact'
@@ -10,6 +11,9 @@ export type AgentSessionConversationCommandResult = {
command: AgentSessionConversationCommand
state: 'completed' | 'unknown'
replacementSessionId?: string
+ /** A /compact the host holds as a card behind work in flight, with `state: 'completed'`: the
+ * card is its one surface from here. Only clients that sent `delivery` receive it. */
+ queued?: AgentSessionQueuedSendReceipt
} &
/** `error`: a sentence for a person; released clients print it as it is. */
(
diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts
index cd51a03f9133..21deeff0e09d 100644
--- a/src/shared/agent-session-failure-copy.ts
+++ b/src/shared/agent-session-failure-copy.ts
@@ -5,6 +5,11 @@
export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.'
export const QUIT_TERMINAL_AGENT = 'Quit that agent to continue the chat here.'
export const START_NEW_CHAT = 'Start a new chat to continue.'
+export const BACKGROUND_TASKS_RUNNING = 'Background tasks are still running.'
+export const WAIT_FOR_BACKGROUND_TASKS = 'Wait for the background tasks to finish.'
+export const AGENT_STARTING = 'The agent is still starting.'
+export const WAIT_FOR_START = 'Wait for the agent to finish starting.'
+export const AGENT_STILL_WORKING = 'The agent is still working.'
/** Every piece a failure sentence is made of, whole so desktop can translate each on its own.
* `{{agent}}` is the agent's name or `theAgent`, `{{command}}` a conversation command's name; the
@@ -88,6 +93,14 @@ export const AGENT_SESSION_FAILURE_COPY = {
notDeliveredSendAgain: 'This message was not delivered. Send it again to continue.',
commandRefused: "This command didn't run.",
commandRefusedTryAgain: "This command didn't run. Try it again.",
+ // What kept a command from running, in the words its refusal has everywhere.
+ backgroundTasksRunning: BACKGROUND_TASKS_RUNNING,
+ waitForBackgroundTasks: WAIT_FOR_BACKGROUND_TASKS,
+ agentStarting: AGENT_STARTING,
+ waitForStart: WAIT_FOR_START,
+ agentStillWorking: AGENT_STILL_WORKING,
+ runCommandWhenDone: "Run /{{command}} when it's done.",
+ commandAfterAnswer: "Answer the agent's question or approval, then run /{{command}}.",
compactionFailed: 'Compaction failed.',
compactionFailedQuoted: 'Compaction failed: {{detail}}.',
compactionUnconfirmed: 'Compaction completion is unconfirmed.',
diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts
index ca219c0c6f35..fbf1a61c07b0 100644
--- a/src/shared/agent-session-failure-words.ts
+++ b/src/shared/agent-session-failure-words.ts
@@ -27,6 +27,7 @@ import {
} from './agent-session-failure-copy'
import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals'
import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words'
+import { commandRefusedByReason } from './agent-session-command-refusal-words'
import { joinSentences } from './sentence-joining'
import {
DISPATCH_REJECTED_CANCELLED,
@@ -243,8 +244,9 @@ const FAILURE_SENTENCES = {
hostRestarted: (_context, _fact, _surface, say) => say('hostRestarted'),
notDelivered: ({ retryControl }, _fact, _surface, say) =>
say(retryControl ? 'notDelivered' : 'notDeliveredSendAgain'),
- commandRefused: ({ retryControl }, _fact, _surface, say) =>
- say(retryControl ? 'commandRefused' : 'commandRefusedTryAgain'),
+ commandRefused: (context, fact, _surface, say) =>
+ commandRefusedByReason(say, context, fact) ??
+ say(context.retryControl ? 'commandRefused' : 'commandRefusedTryAgain'),
compactionFailed: (_context, fact, _surface, say) =>
quotingPersonDetail(say, 'compactionFailed', 'compactionFailedQuoted', fact.detail),
compactionUnconfirmed: (_context, _fact, _surface, say) => say('compactionUnconfirmed'),
diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts
index ca9ebfb67c39..b1257fb5c17c 100644
--- a/src/shared/agent-session-refusal-notice.test.ts
+++ b/src/shared/agent-session-refusal-notice.test.ts
@@ -44,6 +44,8 @@ const WRITES: AgentSessionWriteKind[] = [
'answer',
'option',
'command',
+ 'clear',
+ 'compact',
'goal'
]
const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.'
@@ -92,6 +94,8 @@ const NOT_DONE: Record =
answer: 'notDoneAnswer',
option: 'notDoneOption',
command: 'notDoneCommand',
+ clear: 'notDoneCommand',
+ compact: 'notDoneCommand',
goal: 'notDoneGoal'
}
@@ -453,8 +457,17 @@ describe('the notice for every reason a host names', () => {
write !== 'read-history'
const saysNotDone =
write === 'read-history' && failure.code === 'agent_session_journal_unreadable'
+ // "Run /clear when it's done" already says the command has yet to happen.
+ const clearWhileWorking = (
+ [
+ 'runClearWhenDone',
+ 'clearAfterAnswer',
+ 'runCompactWhenDone',
+ 'compactAfterAnswer'
+ ] as const
+ ).some((sentence) => parts.includes(sentence))
expect(notDone, cell).toEqual(
- answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]]
+ answeredAway || unsupported || saysNotDone || clearWhileWorking ? [] : [NOT_DONE[write]]
)
}
})
@@ -617,6 +630,57 @@ describe('a chat whose history the host could not open', () => {
})
})
+describe('a /clear or /compact refused while the agent works', () => {
+ const refused = (reason: 'turnActive' | 'messagesUnsettled' | 'promptPending') =>
+ ({ kind: 'refused', code: 'agent_session_operation_invalid', details: { reason } }) as const
+
+ it('says one plain sentence of what the person sees and can do, whichever reason', () => {
+ for (const reason of ['turnActive', 'messagesUnsettled'] as const) {
+ expect(
+ agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts(refused(reason), 'clear'))
+ ).toBe("The agent is still working. Run /clear when it's done.")
+ }
+ expect(
+ agentSessionWriteNoticeEnglish(
+ agentSessionWriteNoticeParts(refused('promptPending'), 'clear')
+ )
+ ).toBe("Answer the agent's question or approval, then run /clear.")
+ })
+
+ it('says the same for a /compact, which a host without the queue still refuses', () => {
+ for (const reason of ['turnActive', 'messagesUnsettled'] as const) {
+ expect(
+ agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts(refused(reason), 'compact'))
+ ).toBe("The agent is still working. Run /compact when it's done.")
+ }
+ expect(
+ agentSessionWriteNoticeEnglish(
+ agentSessionWriteNoticeParts(refused('promptPending'), 'compact')
+ )
+ ).toBe("Answer the agent's question or approval, then run /compact.")
+ })
+
+ it('leaves a command this build does not know its own words', () => {
+ expect(agentSessionWriteNoticeParts(refused('turnActive'), 'command')).toEqual([
+ 'turnActive',
+ 'notDoneCommand',
+ 'waitForTurn'
+ ])
+ })
+
+ it('is the write a /clear or /compact is, and only those', () => {
+ expect(
+ agentSessionWriteKindForMethod('agentSession.conversationCommand', { command: 'clear' })
+ ).toBe('clear')
+ expect(
+ agentSessionWriteKindForMethod('agentSession.conversationCommand', { command: 'compact' })
+ ).toBe('compact')
+ expect(
+ agentSessionWriteKindForMethod('agentSession.conversationCommand', { command: 'rewind' })
+ ).toBe('command')
+ })
+})
+
// For a line that already says what did not happen and shows its own Retry, such as a chat that
// could not start: the Retry is the step for retrying and for sending again, and only for those.
describe('agentSessionRefusalCauseParts', () => {
diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts
index c95f175f0cb0..f81e1b51a901 100644
--- a/src/shared/agent-session-refusal-notice.ts
+++ b/src/shared/agent-session-refusal-notice.ts
@@ -46,9 +46,28 @@ const NOT_DONE: Record =
answer: 'notDoneAnswer',
option: 'notDoneOption',
command: 'notDoneCommand',
+ clear: 'notDoneCommand',
+ compact: 'notDoneCommand',
goal: 'notDoneGoal'
}
+/** A /clear or /compact refused because the agent is working: one plain sentence for every
+ * reason that is, saying only what the person sees and can do. */
+const COMMAND_WHILE_WORKING: Partial<
+ Record>>
+> = {
+ clear: {
+ turnActive: ['agentStillWorking', 'runClearWhenDone'],
+ messagesUnsettled: ['agentStillWorking', 'runClearWhenDone'],
+ promptPending: ['clearAfterAnswer']
+ },
+ compact: {
+ turnActive: ['agentStillWorking', 'runCompactWhenDone'],
+ messagesUnsettled: ['agentStillWorking', 'runCompactWhenDone'],
+ promptPending: ['compactAfterAnswer']
+ }
+}
+
/** That the write did not happen, for one that a second attempt can carry out. Only the phone says
* how: its message goes back to the composer and it has no Retry control. Everywhere else the
* control that sent the write is the way to try again. */
@@ -94,6 +113,13 @@ function reasonParts(
}
]
}
+ const commandWhileWorking =
+ failure.code === 'agent_session_operation_invalid'
+ ? COMMAND_WHILE_WORKING[write]?.[failure.details?.reason ?? '']
+ : undefined
+ if (commandWhileWorking) {
+ return commandWhileWorking
+ }
const words = agentSessionRefusalReasonWords(failure)
if (!words || 'words' in words) {
return undefined
diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts
index 2a3124ae73bf..20dc98563901 100644
--- a/src/shared/agent-session-write-failure.ts
+++ b/src/shared/agent-session-write-failure.ts
@@ -31,6 +31,9 @@ export type AgentSessionWriteKind =
| 'answer'
| 'option'
| 'command'
+ /** A /clear or /compact: refused while the agent works, in words of its own. */
+ | 'clear'
+ | 'compact'
| 'goal'
/** The kind of write an `agentSession.*` call stands for. */
@@ -57,6 +60,12 @@ export function agentSessionWriteKindForMethod(
if (fingerprintMethod === 'agentSession.threadGoal') {
return 'goal'
}
+ if (
+ fingerprintMethod === 'agentSession.conversationCommand' &&
+ (fields.command === 'clear' || fields.command === 'compact')
+ ) {
+ return fields.command
+ }
return 'command'
}
diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts
index 084f23eb1f7f..64ff3416558c 100644
--- a/src/shared/agent-session-write-notice-copy.ts
+++ b/src/shared/agent-session-write-notice-copy.ts
@@ -2,9 +2,14 @@
import type { AgentSessionFailureFact } from './agent-session-failure'
import {
+ AGENT_STARTING,
+ AGENT_STILL_WORKING,
+ BACKGROUND_TASKS_RUNNING,
QUIT_TERMINAL_AGENT,
START_NEW_CHAT,
- TERMINAL_AGENT_HOLDS_CHAT
+ TERMINAL_AGENT_HOLDS_CHAT,
+ WAIT_FOR_BACKGROUND_TASKS,
+ WAIT_FOR_START
} from './agent-session-failure-copy'
import type {
AgentSessionFailureSurface,
@@ -52,16 +57,25 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = {
clearUnfinished: "The last /clear didn't finish.",
commandRunning: 'A /compact or /clear is still running.',
waitForCommand: 'Wait for the /compact or /clear to finish.',
- agentStarting: 'The agent is still starting.',
- waitForStart: 'Wait for the agent to finish starting.',
+ agentStarting: AGENT_STARTING,
+ waitForStart: WAIT_FOR_START,
turnActive: 'The agent is still responding.',
waitForTurn: 'Wait for the agent to finish responding, or stop it.',
promptPending: 'The agent is waiting for an answer to a question or approval.',
answerFirst: 'Answer the question or approval first.',
- backgroundTasksRunning: 'Background tasks are still running.',
- waitForBackgroundTasks: 'Wait for the background tasks to finish.',
+ backgroundTasksRunning: BACKGROUND_TASKS_RUNNING,
+ waitForBackgroundTasks: WAIT_FOR_BACKGROUND_TASKS,
messagesUnsettled: "A message you sent earlier isn't confirmed yet.",
settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.',
+ agentStillWorking: AGENT_STILL_WORKING,
+ runClearWhenDone: "Run /clear when it's done.",
+ clearAfterAnswer: "Answer the agent's question or approval, then run /clear.",
+ runCompactWhenDone: "Run /compact when it's done.",
+ compactAfterAnswer: "Answer the agent's question or approval, then run /compact.",
+ clearAfterRetry: 'Retry your earlier message, then run /clear.',
+ compactAfterRetry: 'Retry your earlier message, then run /compact.',
+ clearAfterSending: 'Your earlier message is still being sent. Run /clear once it has gone.',
+ compactAfterSending: 'Your earlier message is still being sent. Run /compact once it has gone.',
queueTooLarge: 'Too much text is waiting in the queue.',
shrinkQueue: 'Delete a queued message, or wait for one to go through, then try again.',
optionRejected: "The agent didn't accept this setting.",
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index 4dd12da66ea8..5575a3e5b47c 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -47,17 +47,7 @@ export {
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability'
import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities'
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
-import {
- SKILL_BUNDLE_INSTALL_CAPABILITY,
- SKILL_DELETE_CAPABILITY,
- SKILL_INSTALL_CAPABILITY,
- SKILL_INSTALL_CANCEL_CAPABILITY,
- SKILL_INSTALL_PROGRESS_CAPABILITY,
- SKILL_INSTALL_PROVIDERS_CAPABILITY,
- SKILL_INSTALL_RESULT_V2_CAPABILITY,
- SKILL_MANAGEMENT_CAPABILITY,
- SKILL_UPLOAD_CAPABILITY
-} from './skill-install-capability'
+import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability'
import {
AGENT_LAUNCH_RUNTIME_CAPABILITIES,
AGENT_LAUNCH_RUNTIME_CAPABILITY
@@ -248,6 +238,12 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY =
// gates the rollout.
export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY =
'agent-session.queued-messages.v1' as const
+// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects
+// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued
+// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too:
+// the card is the only place the waiting command shows.
+export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY =
+ 'agent-session.queued-commands.v1' as const
// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific
// journal and lifecycle surfaces independently from Codex support.
export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY =
@@ -397,6 +393,7 @@ export const RUNTIME_CAPABILITIES = [
// turn starts, so a client may gate on either.
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY,
+ AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY,
...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES,
...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES,
@@ -419,15 +416,7 @@ export const RUNTIME_CAPABILITIES = [
ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY,
DATA_ACCOUNT_RUNTIME_CAPABILITY,
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY,
- SKILL_INSTALL_CAPABILITY,
- SKILL_BUNDLE_INSTALL_CAPABILITY,
- SKILL_INSTALL_CANCEL_CAPABILITY,
- SKILL_INSTALL_PROGRESS_CAPABILITY,
- SKILL_INSTALL_RESULT_V2_CAPABILITY,
- SKILL_UPLOAD_CAPABILITY,
- SKILL_MANAGEMENT_CAPABILITY,
- SKILL_INSTALL_PROVIDERS_CAPABILITY,
- SKILL_DELETE_CAPABILITY,
+ ...SKILL_RUNTIME_CAPABILITIES,
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
diff --git a/src/shared/returned-draft-text.test.ts b/src/shared/returned-draft-text.test.ts
index 79f27507cde0..9899d7912ca5 100644
--- a/src/shared/returned-draft-text.test.ts
+++ b/src/shared/returned-draft-text.test.ts
@@ -20,4 +20,15 @@ describe('appendReturnedDraftText', () => {
it('still appends text that only ends a line of the draft', () => {
expect(appendReturnedDraftText('say ping', 'ping')).toBe('say ping\n\nping')
})
+
+ it('a lone conversation command gives way to the message handed back, which it could not carry', () => {
+ expect(appendReturnedDraftText('/compact', 'my message')).toBe('my message')
+ expect(appendReturnedDraftText(' /clear\n', 'my message')).toBe('my message')
+ // Anything more than the command is the person's text, and stays.
+ expect(appendReturnedDraftText('/compact now', 'my message')).toBe('/compact now\n\nmy message')
+ expect(appendReturnedDraftText('a/b', 'my message')).toBe('a/b\n\nmy message')
+ // A slash word that is not a conversation command is the person's text too.
+ expect(appendReturnedDraftText('/clearly', 'my message')).toBe('/clearly\n\nmy message')
+ expect(appendReturnedDraftText('/tmp', 'my message')).toBe('/tmp\n\nmy message')
+ })
})
diff --git a/src/shared/returned-draft-text.ts b/src/shared/returned-draft-text.ts
index 4dc67520d7fa..b60388fe84f8 100644
--- a/src/shared/returned-draft-text.ts
+++ b/src/shared/returned-draft-text.ts
@@ -1,7 +1,11 @@
+import { isLoneStructuredAgentSessionConversationCommand } from './structured-agent-session-composer'
+
/**
* Puts text a chat handed back after what the composer already holds, a blank line apart. It never
- * replaces typed text (a whitespace-only draft counts as empty), and adds nothing when the draft
- * already ends with that text, so handing the same text back twice leaves one copy.
+ * replaces typed text (a whitespace-only draft counts as empty), except a lone conversation command
+ * (`/compact`, `/clear`), which takes no text after it and was never sent: the message takes its place, so
+ * neither is left unusable. It adds nothing when the draft already ends with that text, so handing
+ * the same text back twice leaves one copy.
*/
export function appendReturnedDraftText(draft: string, text: string): string {
const returned = text.trimEnd()
@@ -9,7 +13,7 @@ export function appendReturnedDraftText(draft: string, text: string): string {
return draft
}
const kept = draft.trimEnd()
- if (kept === '') {
+ if (kept === '' || isLoneStructuredAgentSessionConversationCommand(kept)) {
return text
}
if (kept === returned || kept.endsWith(`\n\n${returned}`)) {
diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts
index d3ed6283c48a..bf5095ee057b 100644
--- a/src/shared/rpc-contract/structured-agent-session-params.ts
+++ b/src/shared/rpc-contract/structured-agent-session-params.ts
@@ -306,7 +306,12 @@ export const ModelCatalogParams = z.strictObject({
export const ConversationCommandParams = z
.object({
envelope: MutationEnvelope,
- command: z.enum(['clear', 'compact'])
+ command: z.enum(['clear', 'compact']),
+ /** A /compact while the agent is working waits as a host-held card, like a queued send.
+ * Strict object, so an older host refuses it: clients send it only when
+ * `agent-session.queued-commands.v1` is advertised. A /clear never waits: its operation
+ * fingerprints no `delivery`, so one sent with it is refused as a conflict. */
+ delivery: z.literal('queue-if-active').optional()
})
.strict()
diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts
index 300bb07564bd..a03af0295821 100644
--- a/src/shared/skill-install-capability.ts
+++ b/src/shared/skill-install-capability.ts
@@ -20,3 +20,15 @@ export const SKILL_DELETE_CAPABILITY = 'skills.delete.v1' as const
export const SKILL_DELETE_UPDATE_REQUIRED_MESSAGE =
'Update Orca on the selected machine to delete skills.'
+
+export const SKILL_RUNTIME_CAPABILITIES = [
+ SKILL_INSTALL_CAPABILITY,
+ SKILL_BUNDLE_INSTALL_CAPABILITY,
+ SKILL_INSTALL_CANCEL_CAPABILITY,
+ SKILL_INSTALL_PROGRESS_CAPABILITY,
+ SKILL_INSTALL_RESULT_V2_CAPABILITY,
+ SKILL_UPLOAD_CAPABILITY,
+ SKILL_MANAGEMENT_CAPABILITY,
+ SKILL_INSTALL_PROVIDERS_CAPABILITY,
+ SKILL_DELETE_CAPABILITY
+] as const
diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts
index 5076e6023d14..b6ba64170e1a 100644
--- a/src/shared/structured-agent-session-coalescer.test.ts
+++ b/src/shared/structured-agent-session-coalescer.test.ts
@@ -69,64 +69,56 @@ describe('structured agent session event coalescer', () => {
expect(events[0]).toMatchObject({ activity: null })
})
- it('preserves a queued-message list when later coalesced frames omit it', () => {
+ it('delivers each queue list at once, in order, each with the pause it was published with', () => {
const events: AgentSessionSubscribeEvent[] = []
const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event))
- const list = [
- {
- messageId: 'draft-1',
- position: 1,
- body: { kind: 'message' as const, role: 'user' as const, blocks: [] },
- state: 'waiting' as const
- }
- ]
+ const draft = {
+ messageId: 'draft-1',
+ position: 1,
+ body: { kind: 'message' as const, role: 'user' as const, blocks: [] },
+ state: 'waiting' as const
+ }
- coalescer.push({ ...batch(1), queuedMessages: list })
- coalescer.push(batch(2))
+ coalescer.push({ ...batch(1), queuedMessages: [draft], queuePause: { reason: 'stopped' } })
+ coalescer.push({ ...batch(2), queuedMessages: [], queuePause: null })
+ // A later frame with no list leaves the delivered ones as they are.
+ coalescer.push(batch(3))
coalescer.flush()
- expect(events).toHaveLength(1)
- expect(events[0]).toMatchObject({ queuedMessages: list })
+ expect(events).toHaveLength(3)
+ expect(events[0]).toMatchObject({
+ queuedMessages: [draft],
+ queuePause: { reason: 'stopped' }
+ })
+ expect(events[1]).toMatchObject({ queuedMessages: [], queuePause: null })
+ expect(events[2]).not.toHaveProperty('queuedMessages')
})
- it('keeps the latest queued-message list, an emptied one included', () => {
+ it('passes a queue list through at once, with anything it held merged ahead of it', () => {
const events: AgentSessionSubscribeEvent[] = []
const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event))
-
+ coalescer.push(batch(1))
coalescer.push({
- ...batch(1),
+ ...batch(2),
queuedMessages: [
{
- messageId: 'draft-1',
+ messageId: 'card-1',
position: 1,
- body: { kind: 'message' as const, role: 'user' as const, blocks: [] },
- state: 'waiting' as const
+ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'queued' }] },
+ state: 'waiting'
}
- ]
+ ],
+ queuePause: null
})
- coalescer.push({ ...batch(2), queuedMessages: [] })
- coalescer.flush()
-
- expect(events).toHaveLength(1)
- expect(events[0]).toMatchObject({ queuedMessages: [] })
- })
-
- it('keeps the queue pause with the list it was published with', () => {
- const events: AgentSessionSubscribeEvent[] = []
- const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event))
-
- coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'stopped' } })
- coalescer.push(batch(2))
- coalescer.flush()
- expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'stopped' } })
-
- coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null })
- coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } })
- coalescer.flush()
- expect(events[1]).toMatchObject({ queuePause: { reason: 'stopped' } })
+ // No wait for the coalescing delay: the card lands with the frame that carries it.
+ expect(events.map((event) => event.type === 'batch' && event.batch.cursor.sequence)).toEqual([
+ 1, 2
+ ])
+ expect(events[1]).toMatchObject({ queuedMessages: [{ messageId: 'card-1' }] })
+ coalescer.dispose()
})
- it("keeps the queue's next card with its list when a token batch merges into it", () => {
+ it("delivers the queue's next card with its list, ahead of a later token batch", () => {
const events: AgentSessionSubscribeEvent[] = []
const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event))
coalescer.push({ ...batch(1), queuedMessages: [], nextQueuedMessageId: 'draft-1' })
@@ -144,9 +136,11 @@ describe('structured agent session event coalescer', () => {
const tokens = batch(2)
coalescer.push({ ...tokens, batch: { ...tokens.batch, items: [token] } })
coalescer.flush()
- expect(events).toHaveLength(1)
+ expect(events).toHaveLength(2)
expect(events[0]).toMatchObject({ nextQueuedMessageId: 'draft-1' })
+ expect(events[1]).not.toHaveProperty('queuedMessages')
})
+
it('keeps the latest turn a coalesced frame carried, and a null one as an answer', () => {
const events: AgentSessionSubscribeEvent[] = []
const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event))
diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts
index a19e584af9b8..f80658838dc1 100644
--- a/src/shared/structured-agent-session-coalescer.ts
+++ b/src/shared/structured-agent-session-coalescer.ts
@@ -6,19 +6,13 @@ export const STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS = 48
function bypassCoalescing(event: AgentSessionSubscribeEvent): boolean {
return (
event.type !== 'batch' ||
+ // A queue list lands at once: held, the card would be missing for a moment after the send's
+ // own "Sending…" card went with its queued answer.
+ event.queuedMessages !== undefined ||
event.batch.items.some((item) => item.body.kind !== 'message' || item.body.role !== 'assistant')
)
}
-/** The list and what rides with it. */
-function queuePublicationOf(event: Extract) {
- return {
- queuedMessages: event.queuedMessages,
- queuePause: event.queuePause ?? null,
- nextQueuedMessageId: event.nextQueuedMessageId ?? null
- }
-}
-
function mergeBatch(
left: Extract,
right: Extract
@@ -40,13 +34,6 @@ function mergeBatch(
...(right.commands !== undefined || left.commands !== undefined
? { commands: right.commands !== undefined ? right.commands : left.commands }
: {}),
- // Whole-list publication, latest wins: dropping it here would lose a draft
- // update that rode a coalesced token frame. The pause rides with its list.
- ...(right.queuedMessages !== undefined
- ? queuePublicationOf(right)
- : left.queuedMessages !== undefined
- ? queuePublicationOf(left)
- : {}),
sessionId: right.sessionId,
batch: {
cursor: right.batch.cursor,
diff --git a/src/shared/structured-agent-session-command-refusal-cause.ts b/src/shared/structured-agent-session-command-refusal-cause.ts
new file mode 100644
index 000000000000..4720435c620e
--- /dev/null
+++ b/src/shared/structured-agent-session-command-refusal-cause.ts
@@ -0,0 +1,27 @@
+import {
+ isAgentSessionConversationCommand,
+ type AgentSessionConversationCommandResult
+} from './agent-session-conversation-command'
+import { readWholeAgentSessionFailureFact } from './agent-session-failure'
+import type { StructuredAgentSessionCommandRefusalCause } from './structured-agent-session-composer'
+
+/** The host's refusal reasons whose words name something a chat shows. */
+const CAUSE_OF_HOST_REFUSAL: Partial> = {
+ turnActive: 'working',
+ messagesUnsettled: 'working',
+ promptPending: 'prompt',
+ backgroundTasksRunning: 'background'
+}
+
+/** What a host's refusal of a command waits on, as a chat shows it; undefined for any other
+ * failure, or one this build can't read whole. */
+export function structuredAgentSessionCommandHostRefusalCause(
+ result: AgentSessionConversationCommandResult
+): StructuredAgentSessionCommandRefusalCause | undefined {
+ const details = isAgentSessionConversationCommand(result.command)
+ ? readWholeAgentSessionFailureFact(result.failure)?.refusal?.details
+ : undefined
+ return details && 'reason' in details && typeof details.reason === 'string'
+ ? CAUSE_OF_HOST_REFUSAL[details.reason]
+ : undefined
+}
diff --git a/src/shared/structured-agent-session-command-turn-identity.ts b/src/shared/structured-agent-session-command-turn-identity.ts
new file mode 100644
index 000000000000..cd13207e7fe4
--- /dev/null
+++ b/src/shared/structured-agent-session-command-turn-identity.ts
@@ -0,0 +1,28 @@
+import { agentJournalItemKey } from './agent-session-journal-item-key'
+import type { AgentJournalItemIdentity } from './agent-session-journal-types'
+
+/** The turn a conversation command such as `/compact` runs as, keyed by its submission. The queue
+ * reads it too: a refused command card is spent only once this turn has said why. */
+export function structuredAgentSessionCommandTurnIdentity(
+ clientMessageId: string
+): AgentJournalItemIdentity {
+ return { provider: 'orca', clientMessageId: `command-turn:${clientMessageId}` }
+}
+
+/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the
+ * host's Stop and delivery gate tell a command's turn from any other. */
+export function structuredAgentSessionCommandTurn(clientMessageId: string): {
+ identity: AgentJournalItemIdentity
+ itemId: string
+ turnId: string
+ /** The command's one result row, inside its turn. */
+ resultIdentity: AgentJournalItemIdentity
+} {
+ const identity = structuredAgentSessionCommandTurnIdentity(clientMessageId)
+ return {
+ identity,
+ itemId: agentJournalItemKey(identity),
+ turnId: `compact:${clientMessageId}`,
+ resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` }
+ }
+}
diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts
index 0673d62dd26b..4bdea4f68bc0 100644
--- a/src/shared/structured-agent-session-composer.ts
+++ b/src/shared/structured-agent-session-composer.ts
@@ -36,16 +36,27 @@ export type StructuredAgentSessionComposerOptions = {
conversationCommands?: readonly AgentSessionConversationCommand[]
runConversationCommand?: (
command: AgentSessionConversationCommand
- ) => Promise<{ accepted: boolean; error: string | null }>
+ ) => Promise>
/** Present only where the host can set this session's goal; otherwise `/goal`
* stays message text the agent acts on itself. */
setThreadGoalObjective?: (objective: string) => Promise
}
+/** What the chat shows a refused command waiting on: the agent working, a pending prompt, its
+ * background tasks, a message of this window's still being sent, or one showing its Retry. */
+export type StructuredAgentSessionCommandRefusalCause =
+ | 'working'
+ | 'prompt'
+ | 'background'
+ | 'sending'
+ | 'retry'
+
export type StructuredAgentSessionCommandOutcome = {
handled: boolean
accepted: boolean
error: string | null
+ /** The refusal is said only while this still holds. */
+ refusedWhile?: StructuredAgentSessionCommandRefusalCause
}
function commandParts(text: string): { name: string; argument: string } | null {
@@ -105,6 +116,16 @@ export function isStructuredAgentSessionComposerCommand(
)
}
+/** `/clear` or `/compact` alone: a conversation command with nothing after it. */
+export function isLoneStructuredAgentSessionConversationCommand(text: string): boolean {
+ const command = commandParts(text.trim())
+ return Boolean(
+ command &&
+ command.argument === '' &&
+ CONVERSATION_COMMANDS.some((entry) => entry.name === command.name)
+ )
+}
+
/** `/goal …`, which the host answers only where it can set this session's goal. */
export function isStructuredAgentSessionGoalCommand(text: string): boolean {
return commandParts(text)?.name === 'goal'