fix(native-chat): a /compact sent while the agent works waits in line instead of being refused (#25704)

* fix(native-chat): a /compact sent while the agent works waits in line instead of being refused

* test(native-chat): build the admission test's submissions without a type assertion

* fix(native-chat): a /clear refused while the agent works says so plainly; a /compact behind an unsent message waits for it quietly

* test(native-chat): name the admission test's cases for the sends they model

* fix(native-chat): a /compact refused while the agent works reads plainly too; a command card offers no Edit

* test(native-chat): a refused /compact reads the plain sentence

* fix(native-chat): a refused command card is spent and said once; a command card offers Send while the agent is idle; /compact behind an unsent message is not taken

* test(native-chat): keep the queued receipt's narrowing inside the wait

* fix(native-chat): spend a command card only when its own turn says why; refuse a queued command without a hand-off, with its reason; a queue send on its way reads as sending

* fix(native-chat): a recorded send is no sending card; queue lists land at once; a spent card's wait says to run it again; only /compact or /clear give way to returned text

* refactor(native-chat): derive the sending cards inside the queued-messages hook

* fix(native-chat): keep protocol-version and the agent-session params under the line limit; the send capabilities get their own module

* fix(native-chat): with the agent idle, a command behind this window's own unsent message says it is still being sent

* fix(native-chat): the queued-commands capability stays a plain constant beside the others; main's send capabilities back to its text

* test(native-chat): keep the rewind suite under its line limit after main's growth

* fix(native-chat): one import per module in the command send; a rewind on its way holds commands, tested

* test(native-chat): type the rewind module mock with a type import

* test(native-chat): a command card cut short by a restart waits or is kept like any card; kept-card captions name only controls shown

* fix(native-chat): a held command card, which the queue skips, no longer forces later sends to queue

* fix(native-chat): a command's refusal line goes once what it waits on is gone

A refusal that names something the chat shows (the agent working, a pending prompt, background
tasks, an unsent message) is said only while that still holds, derived each render, and dropped
once it ends. Other composer errors are unchanged.

* fix(mobile): a command's refusal banner goes once what it waits on is gone

The phone's banner keeps a refusal naming the agent working or a pending prompt only while the
chat still shows it, dropped each render once it ends. Background tasks, which the phone can't
see, and generic errors behave as before. The host-reason mapping is shared with desktop.

* fix(native-chat): a refusal behind an earlier message names whether it is being sent or offers Retry

Each line now goes when its own state ends: a send that fails stops being sent, and a resend
stops offering Retry.

* test(native-chat): a Stop behind a typed /compact keeps the taken-back message on screen; the in-doubt case says so

* fix(native-chat): a command refused while the queue is about to send reads working, as the pane does

* Update held command fixture for the merged queue model

* Restart one host at a time in the recovery ordering test

* fix: preserve person queue reserve for chat commands
This commit is contained in:
Brennan Benson
2026-10-08 13:32:20 -07:00
committed by GitHub
parent d4acd11ae5
commit 4735ecefac
95 changed files with 3361 additions and 522 deletions
+1
View File
@@ -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/**/*",
@@ -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)
@@ -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 (
<View
key={card.messageId}
@@ -142,31 +142,34 @@ export function MobileNativeChatQueuedMessages({
</Text>
) : null}
</View>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || steerHeld }}
accessibilityLabel={
returned
? 'Send this message again'
: card.paused
? 'Send this message'
: 'Submit without interrupting the model'
}
style={({ pressed }) => [
styles.textAction,
pressed && styles.pressed,
(busy || steerHeld) && styles.disabled
]}
disabled={busy || steerHeld}
onPress={() => void run(card.messageId, onSend)}
>
{steers ? (
<CornerDownRight size={12} color={colors.textPrimary} strokeWidth={2} />
) : (
<Send size={12} color={colors.textPrimary} strokeWidth={2} />
)}
<Text style={styles.actionLabel}>{steers ? 'Steer' : 'Send'}</Text>
</Pressable>
{/* A command never steers: its Send shows only while the agent is idle. */}
{card.waitsForAgent ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || steerHeld }}
accessibilityLabel={
returned
? 'Send this message again'
: steers
? 'Submit without interrupting the model'
: 'Send this message'
}
style={({ pressed }) => [
styles.textAction,
pressed && styles.pressed,
(busy || steerHeld) && styles.disabled
]}
disabled={busy || steerHeld}
onPress={() => void run(card.messageId, onSend)}
>
{steers ? (
<CornerDownRight size={12} color={colors.textPrimary} strokeWidth={2} />
) : (
<Send size={12} color={colors.textPrimary} strokeWidth={2} />
)}
<Text style={styles.actionLabel}>{steers ? 'Steer' : 'Send'}</Text>
</Pressable>
)}
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
@@ -181,20 +184,23 @@ export function MobileNativeChatQueuedMessages({
>
<Trash2 size={14} color={colors.textPrimary} strokeWidth={2} />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
accessibilityLabel="More actions"
style={({ pressed }) => [
styles.iconAction,
pressed && styles.pressed,
busy && styles.disabled
]}
disabled={busy}
onPress={() => setMenuFor(card.messageId)}
>
<MoreHorizontal size={14} color={colors.textPrimary} strokeWidth={2} />
</Pressable>
{/* The menu holds only Edit, which a command does not take. */}
{card.command ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
accessibilityLabel="More actions"
style={({ pressed }) => [
styles.iconAction,
pressed && styles.pressed,
busy && styles.disabled
]}
disabled={busy}
onPress={() => setMenuFor(card.messageId)}
>
<MoreHorizontal size={14} color={colors.textPrimary} strokeWidth={2} />
</Pressable>
)}
</View>
)
})}
@@ -44,6 +44,7 @@ vi.mock('./use-mobile-native-chat-send-error', () => ({
message: null,
show: () => {},
clear: () => {},
keepWhile: () => {},
bannerMountedRef: { current: false }
})
}))
@@ -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. */
@@ -88,6 +88,7 @@ const HOST_SUPPORT = {
promptCancel: true,
questionAnswers: true,
queuedMessages: true,
queuedCommands: false,
statusFeed: true,
quietRepeatedStop: true
}
@@ -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<typeof useMobileStructuredAgentOptions> &
ReturnType<typeof useMobileStructuredAgentTurnTiming> & {
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<MobileNativeChatSendOutcome>
cancel: () => void
permission: MobileChatPermission | null
question: MobileChatQuestion | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
/** 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
}
@@ -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 })
@@ -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)
}
@@ -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()
@@ -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<MobileNativeChatSendOutcome | null> {
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'
}
@@ -30,6 +30,62 @@ function draft(overrides: Partial<AgentSessionQueuedMessage> & { 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([])
@@ -27,6 +27,10 @@ export type MobileQueuedMessageCard = {
caption: string | null
/** "From <name>" 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<AgentSessionQueuePause, 'reaso
export function mobileQueuedMessageCards(
queuedMessages: readonly AgentSessionQueuedMessage[] | null,
submissions: readonly Pick<AgentJournalSubmission, 'queuedMessageId' | 'dispatchState'>[],
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
@@ -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<typeof useMobileNativeChatSessionLane>[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,
@@ -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<typeof useMobileNativeChatSendError>
@@ -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<void> {
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'))
@@ -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<Record<StructuredAgentSessionCommandRefusalCause, boolean>>
>
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<boolean>
} {
const [message, setMessage] = useState<string | null>(null)
const [refusedWhile, setRefusedWhile] = useState<
StructuredAgentSessionCommandRefusalCause | undefined
>(undefined)
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 }
}
@@ -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
}): {
@@ -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'
@@ -122,6 +122,7 @@ function Harness({
promptCancel: promptCancelSupported,
questionAnswers: questionAnswersSupported,
queuedMessages: false,
queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false
},
@@ -16,6 +16,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = {
promptCancel: false,
questionAnswers: false,
queuedMessages: true,
queuedCommands: false,
statusFeed: false,
quietRepeatedStop: false
}
@@ -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<typeof useMobileStructuredAgentOptions> &
ReturnType<typeof useMobileStructuredAgentTurnTiming> & {
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<MobileNativeChatSendOutcome>
cancel: () => void
permission: MobileChatPermission | null
question: MobileChatQuestion | null
respondPermission: (optionId: string) => Promise<boolean>
respondQuestion: (answer: string) => Promise<boolean>
cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise<boolean>
/** 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
}
}
@@ -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?.()
@@ -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,
@@ -265,6 +265,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => {
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
queuedCommands: false,
quietRepeatedStop: false,
statusFeed
},
@@ -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<AgentJournalSubmission, 'reason'> & { rejection?: unknown }
@@ -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))
)
}
@@ -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,
@@ -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<AgentSessionJournal, 'activeTurnId'>
@@ -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(
@@ -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<ConversationCommandAcceptance> {
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 }
}
@@ -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<void> {
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 })
}
@@ -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<string> {
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<string> {
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<string, unknown>) =>
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([])
})
@@ -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<void> | 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<NonNullable<StructuredAgentSessionAdapter['compact']>> = 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,
@@ -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,
@@ -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,
@@ -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(
@@ -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({
@@ -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<string, Partial<AgentJournalSubmission>> = {
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')
})
})
@@ -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',
@@ -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
@@ -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
})
})
@@ -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<AgentSessionMutationResult<ConversationCommandAcceptance>> {
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 &&
@@ -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()
@@ -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({
</p>
) : null}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="xs" onClick={onSteer} disabled={steerHeld}>
{sendNow.steers ? <CornerDownRight className="size-3" /> : <Send className="size-3" />}
{sendNow.label}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<span className="flex items-center gap-2">
<span>{sendNow.hint}</span>
{showsSteerShortcut ? (
<ShortcutKeyCombo keys={[isMac ? '⌘' : 'Ctrl', isMac ? '⏎' : 'Enter']} />
) : null}
</span>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate('components.native-chat.queuedMessages.delete', 'Delete')}
onClick={onDelete}
>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('components.native-chat.queuedMessages.delete', 'Delete')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'components.native-chat.queuedMessages.moreActions',
'More actions'
)}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onEdit}>
<Pencil />
{translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
</DropdownMenuItem>
{onTurnOffQueueing ? (
<DropdownMenuItem onSelect={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 : (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
onClick={onSteer}
disabled={steerHeld}
>
{sendNow.steers ? (
<CornerDownRight className="size-3" />
) : (
<Send className="size-3" />
)}
{sendNow.label}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<span className="flex items-center gap-2">
<span>{sendNow.hint}</span>
{showsSteerShortcut ? (
<ShortcutKeyCombo keys={[isMac ? '⌘' : 'Ctrl', isMac ? '⏎' : 'Enter']} />
) : null}
</span>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate('components.native-chat.queuedMessages.delete', 'Delete')}
onClick={onDelete}
>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('components.native-chat.queuedMessages.delete', 'Delete')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'components.native-chat.queuedMessages.moreActions',
'More actions'
)}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* A command's text is not a draft: edited, it would become a message. */}
{card.command ? null : (
<DropdownMenuItem onSelect={onEdit}>
<Pencil />
{translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
</DropdownMenuItem>
)}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{onTurnOffQueueing ? (
<DropdownMenuItem onSelect={onTurnOffQueueing}>
{translate(
'components.native-chat.queuedMessages.turnOffQueueing',
'Turn off queueing'
)}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</li>
)
}
@@ -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(
@@ -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 = () => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="refusal-line-tab"
sessionId="refusal-line-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
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()
})
@@ -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' }))
@@ -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>(): T | null {
return null
}
function widened<T>(value: T): T {
return value
}
function absent<T>(): 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<string, unknown> & {
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,
@@ -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<NativeChatOptionPickerRequest | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
@@ -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
@@ -182,6 +182,34 @@ const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopy
'components.native-chat.failureWords.commandRefusedTryAgain',
COPY.commandRefusedTryAgain
),
backgroundTasksRunning: () =>
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) =>
@@ -110,6 +110,24 @@ const SENTENCES: Record<AgentSessionWriteNoticeSentence, () => 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),
@@ -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<boolean> }
/** `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'
@@ -0,0 +1,13 @@
// Typed starting values for mutable mock slots, so a test can later assign the wider type.
export function nullable<T>(): T | null {
return null
}
export function widened<T>(value: T): T {
return value
}
export function absent<T>(): T | undefined {
return undefined
}
@@ -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<Omit<StructuredAgentSessionCommandOutcome, 'handled'> & { 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 }
@@ -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')
})
@@ -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<AgentSessionPromptResult | null> {
const promptTarget = { itemId: item.itemId, expectedRevision: item.revision }
let fields: Record<string, unknown>
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<AgentSessionPromptResult>(
item.body.kind === 'approval'
? 'agentSession.respondToApproval'
: 'agentSession.respondToQuestion',
`agentSession.respondTo:${item.body.kind}`,
fields
)
}
@@ -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", () => {
@@ -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)
)
}
@@ -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<typeof sendStructuredConversationCommand>[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')
})
})
@@ -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<StructuredAgentSessionCommandOutcome, 'handled'>
/** Which of what a refused command waits on the chat shows right now. */
export type StructuredConversationCommandCauses = Readonly<
Record<StructuredAgentSessionCommandRefusalCause, boolean>
>
/** 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<StructuredConversationCommandHold, 'ahead'>
): 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<StructuredAgentSessionWriteOutcome<AgentSessionConversationCommandResult>>
}): 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<CommandOutcome> {
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<StructuredAgentSessionWriteOutcome<AgentSessionConversationCommandResult>>
}): {
runConversationCommand: (command: AgentSessionConversationCommand) => Promise<CommandOutcome>
/** 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(
@@ -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<NativeChatComposerErrorDetail, 'refusedWhile'>
/** 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<NativeChatComposerNoticeContent | null>(null)
const reportComposerError = useCallback(
(text: string | null, errorText?: string) =>
setContent(text === null ? null : { text, ...(errorText ? { errorText } : {}) }),
const [line, setLine] = useState<ComposerErrorLine | null>(null)
const reportComposerError = useCallback<NativeChatStructuredComposerTransport['onError']>(
(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 }
}
@@ -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'
})
)
})
})
@@ -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 })
})
},
[
@@ -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<typeof useStructuredAgentSession> }) {
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)
})
})
@@ -20,7 +20,8 @@ export function useStructuredAgentSessionCommandWrite(
sessionId: string,
write: StructuredAgentSessionWrite
): (
command: AgentSessionConversationCommand
command: AgentSessionConversationCommand,
delivery?: 'queue-if-active'
) => Promise<StructuredAgentSessionWriteOutcome<AgentSessionConversationCommandResult>> {
// Null once the view left or unmounted, so a reply that lands later releases its hold.
const shown = useRef<string | null>(sessionId)
@@ -34,12 +35,12 @@ export function useStructuredAgentSessionCommandWrite(
}
}, [sessionId])
return useCallback(
async (command) => {
async (command, delivery) => {
const send = () =>
write<AgentSessionConversationCommandResult>(
'agentSession.conversationCommand',
'agentSession.conversationCommand',
{ command }
{ command, ...(delivery ? { delivery } : {}) }
)
if (command !== 'clear') {
return send()
@@ -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]
@@ -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."
})
})
@@ -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<typeof RewindModule>()
return {
...actual,
useStructuredAgentSessionRewind: (
...args: Parameters<typeof actual.useStructuredAgentSessionRewind>
) => {
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<string, unknown>): 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> = {}
): 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 () => {
@@ -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<string, unknown>
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<AgentSessionPromptResult>(
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,
+11
View File
@@ -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.",
+15 -3
View File
@@ -18066,7 +18066,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.",
@@ -18128,7 +18130,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",
@@ -18618,7 +18629,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}}",
+14 -2
View File
@@ -17844,6 +17844,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": {
@@ -17906,7 +17908,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.",
@@ -18229,7 +18240,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}}",
+14 -2
View File
@@ -17945,6 +17945,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": {
@@ -18007,7 +18009,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.",
@@ -18466,7 +18477,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}}",
+14 -2
View File
@@ -17879,6 +17879,8 @@
"providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。",
"providerRetryLastError": "直近のエラー: {{detail}}。",
"previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。",
"runCommandWhenDone": "終わったら /{{command}} を実行してください。",
"commandAfterAnswer": "Agent の質問または承認に回答してから、/{{command}} を実行してください。",
"sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。"
},
"writeNotice": {
@@ -17941,7 +17943,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 では画像の貼り付けはサポートされていません。",
@@ -18400,7 +18411,8 @@
"sendConfirmBody_other": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?",
"sendConfirmClear": "キューを消去",
"sendConfirmSend": "メッセージを送信",
"from": "送信元: {{names}}"
"from": "送信元: {{names}}",
"pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。"
},
"thought": "考えました",
"thoughtForDuration": "{{duration}} 考えました",
+14 -2
View File
@@ -17879,6 +17879,8 @@
"providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.",
"providerRetryLastError": "마지막 오류: {{detail}}.",
"previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다.",
"runCommandWhenDone": "작업이 끝나면 /{{command}}을(를) 실행하세요.",
"commandAfterAnswer": "에이전트의 질문 또는 승인에 응답한 다음 /{{command}}을(를) 실행하세요.",
"sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다."
},
"writeNotice": {
@@ -17941,7 +17943,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": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.",
@@ -18400,7 +18411,8 @@
"sendConfirmBody_other": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?",
"sendConfirmClear": "대기열 지우기",
"sendConfirmSend": "메시지 보내기",
"from": "보낸 사람: {{names}}"
"from": "보낸 사람: {{names}}",
"pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요."
},
"thought": "생각함",
"thoughtForDuration": "{{duration}} 동안 생각함",
+14 -2
View File
@@ -17844,6 +17844,8 @@
"providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。",
"providerRetryLastError": "上次错误:{{detail}}。",
"previousExitUnverifiable": "无法停止之前的 {{agent}}。",
"runCommandWhenDone": "完成后再运行 /{{command}}。",
"commandAfterAnswer": "请先回答智能体的问题或审批,然后再运行 /{{command}}。",
"sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。"
},
"writeNotice": {
@@ -17906,7 +17908,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": "此智能体不支持粘贴图片。",
@@ -18365,7 +18376,8 @@
"sendConfirmBody_other": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?",
"sendConfirmClear": "清空队列",
"sendConfirmSend": "发送消息",
"from": "来自 {{names}}"
"from": "来自 {{names}}",
"pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。"
},
"thought": "已思考",
"thoughtForDuration": "思考了 {{duration}}",
@@ -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
)
}
@@ -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<Record<string, ReasonWords>> = {
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)
}
@@ -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. */
(
+13
View File
@@ -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.',
+4 -2
View File
@@ -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'),
@@ -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<AgentSessionWriteKind, AgentSessionWriteNoticeSentence> =
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', () => {
@@ -46,9 +46,28 @@ const NOT_DONE: Record<AgentSessionWriteKind, AgentSessionWriteNoticeSentence> =
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<AgentSessionWriteKind, Partial<Record<string, AgentSessionWriteNoticeSentence[]>>>
> = {
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
@@ -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'
}
+19 -5
View File
@@ -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.",
+9 -20
View File
@@ -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,
+11
View File
@@ -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')
})
})
+7 -3
View File
@@ -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}`)) {
@@ -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()
+12
View File
@@ -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
@@ -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: [] })
// 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 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' } })
})
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))
@@ -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<AgentSessionSubscribeEvent, { type: 'batch' }>) {
return {
queuedMessages: event.queuedMessages,
queuePause: event.queuePause ?? null,
nextQueuedMessageId: event.nextQueuedMessageId ?? null
}
}
function mergeBatch(
left: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>,
right: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
@@ -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,
@@ -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<Record<string, StructuredAgentSessionCommandRefusalCause>> = {
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
}
@@ -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}` }
}
}
@@ -36,16 +36,27 @@ export type StructuredAgentSessionComposerOptions = {
conversationCommands?: readonly AgentSessionConversationCommand[]
runConversationCommand?: (
command: AgentSessionConversationCommand
) => Promise<{ accepted: boolean; error: string | null }>
) => Promise<Omit<StructuredAgentSessionCommandOutcome, 'handled'>>
/** 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<boolean>
}
/** 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'