mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
+69
@@ -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 })
|
||||
}
|
||||
+549
@@ -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([])
|
||||
})
|
||||
+12
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+26
-16
@@ -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({
|
||||
|
||||
+65
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+10
-7
@@ -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(
|
||||
|
||||
+96
@@ -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()
|
||||
})
|
||||
+5
-2
@@ -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' }))
|
||||
|
||||
+14
-16
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
+208
-4
@@ -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 }
|
||||
}
|
||||
|
||||
+5
-5
@@ -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 })
|
||||
})
|
||||
},
|
||||
[
|
||||
|
||||
+62
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+4
-3
@@ -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()
|
||||
|
||||
+25
-6
@@ -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]
|
||||
|
||||
+1
-2
@@ -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."
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+397
-17
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} 考えました",
|
||||
|
||||
@@ -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}} 동안 생각함",
|
||||
|
||||
@@ -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. */
|
||||
(
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user