Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ab08cfd
fix(native-chat): a /compact sent while the agent works waits in line…
brennanb2025 Oct 5, 2026
463daba
test(native-chat): build the admission test's submissions without a t…
brennanb2025 Oct 5, 2026
159b6ab
fix(native-chat): a /clear refused while the agent works says so plai…
brennanb2025 Oct 5, 2026
10389f0
test(native-chat): name the admission test's cases for the sends they…
brennanb2025 Oct 5, 2026
6c20ec0
fix(native-chat): a /compact refused while the agent works reads plai…
brennanb2025 Oct 6, 2026
0069210
test(native-chat): a refused /compact reads the plain sentence
brennanb2025 Oct 6, 2026
b67b317
fix(native-chat): a refused command card is spent and said once; a co…
brennanb2025 Oct 6, 2026
6efc87d
test(native-chat): keep the queued receipt's narrowing inside the wait
brennanb2025 Oct 6, 2026
02df320
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
030c988
fix(native-chat): spend a command card only when its own turn says wh…
brennanb2025 Oct 6, 2026
f4a8fbf
fix(native-chat): a recorded send is no sending card; queue lists lan…
brennanb2025 Oct 6, 2026
185781d
refactor(native-chat): derive the sending cards inside the queued-mes…
brennanb2025 Oct 6, 2026
aef3e8b
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
09700a1
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
234f7c5
fix(native-chat): keep protocol-version and the agent-session params …
brennanb2025 Oct 6, 2026
c1ff9f7
fix(native-chat): with the agent idle, a command behind this window's…
brennanb2025 Oct 6, 2026
0f62036
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
f91a680
fix(native-chat): the queued-commands capability stays a plain consta…
brennanb2025 Oct 6, 2026
3776b83
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
ab0b289
test(native-chat): keep the rewind suite under its line limit after m…
brennanb2025 Oct 6, 2026
47f9858
fix(native-chat): one import per module in the command send; a rewind…
brennanb2025 Oct 6, 2026
70246d8
test(native-chat): type the rewind module mock with a type import
brennanb2025 Oct 6, 2026
02cf47a
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
de818d7
test(native-chat): a command card cut short by a restart waits or is …
brennanb2025 Oct 6, 2026
338a57c
fix(native-chat): a held command card, which the queue skips, no long…
brennanb2025 Oct 6, 2026
48599fc
fix(native-chat): a command's refusal line goes once what it waits on…
brennanb2025 Oct 6, 2026
2df8b2d
fix(mobile): a command's refusal banner goes once what it waits on is…
brennanb2025 Oct 6, 2026
f6480f0
fix(native-chat): a refusal behind an earlier message names whether i…
brennanb2025 Oct 6, 2026
479a732
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
1d6da76
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 6, 2026
a967125
test(native-chat): a Stop behind a typed /compact keeps the taken-bac…
brennanb2025 Oct 6, 2026
4f73f5e
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 7, 2026
ca5705e
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 7, 2026
27bc442
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 7, 2026
fc7a251
fix(native-chat): a command refused while the queue is about to send …
brennanb2025 Oct 7, 2026
09a24c6
Merge origin/main, keeping recovered messages and commands in one queue
brennanb2025 Oct 7, 2026
c79a243
Update held command fixture for the merged queue model
brennanb2025 Oct 7, 2026
a4c3aa0
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-com…
brennanb2025 Oct 7, 2026
b6cbc76
Restart one host at a time in the recovery ordering test
brennanb2025 Oct 7, 2026
47f4087
Merge origin/main and preserve ordered chat commands with the desktop…
brennanb2025 Oct 8, 2026
b15c597
Merge origin/main and preserve chat command capabilities
brennanb2025 Oct 8, 2026
a3114b7
Merge origin/main and preserve queued chat commands
brennanb2025 Oct 8, 2026
62bde78
fix: preserve person queue reserve for chat commands
brennanb2025 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*",
Expand Down
42 changes: 42 additions & 0 deletions mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 46 additions & 40 deletions mobile/src/session/MobileNativeChatQueuedMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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 }}
Expand All @@ -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>
)
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock('./use-mobile-native-chat-send-error', () => ({
message: null,
show: () => {},
clear: () => {},
keepWhile: () => {},
bannerMountedRef: { current: false }
})
}))
Expand Down
3 changes: 3 additions & 0 deletions mobile/src/session/mobile-native-chat-controller-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const HOST_SUPPORT = {
promptCancel: true,
questionAnswers: true,
queuedMessages: true,
queuedCommands: false,
statusFeed: true,
quietRepeatedStop: true
}
Expand Down
40 changes: 40 additions & 0 deletions mobile/src/session/mobile-structured-agent-session-contract.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +15,7 @@ describe('structuredAgentSessionHostSupport', () => {
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
queuedCommands: false,
quietRepeatedStop: false,
statusFeed: false
}
Expand All @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
83 changes: 81 additions & 2 deletions mobile/src/session/mobile-structured-composer-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Loading
Loading