Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,26 @@ describe('MobileNativeChatQueuedMessages', () => {
expect(flatStyle(caption.props.style).color).not.toBe('#ef4444')
})

it("lets a waiting /clear's caption wrap whole, so its next step is never cut off", async () => {
const waits = 'Waiting for background tasks to finish. Stop them to clear now.'
const mounted = await mount({
cards: [
card({
messageId: 'clear',
text: '/clear',
command: true,
runsOnItsOwn: true,
caption: waits
})
]
})
const caption = mounted.root.find(
(node) => String(node.type) === 'Text' && node.props.children === waits
)
expect(caption.props.numberOfLines).toBeUndefined()
expect(flatStyle(caption.props.style).color).not.toBe('#ef4444')
})

it("lets a returned card's reason wrap whole, in the destructive color, beside an alert", async () => {
const reason =
'The provider did not accept this message: Claude does not support the image type .bmp in a steering message.'
Expand Down
7 changes: 4 additions & 3 deletions mobile/src/session/MobileNativeChatQueuedMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,18 @@ export function MobileNativeChatQueuedMessages({
) : null}
<MobileQueuedCardText text={card.text} />
{card.caption ? (
// A returned card's reason only reads whole, often at its end; a hold is one line.
// A returned card's reason and a waiting /clear's next step only read whole, often
// at their end; a hold is one line.
<Text
style={[styles.caption, returned && styles.captionReturned]}
numberOfLines={returned ? undefined : 1}
numberOfLines={returned || card.runsOnItsOwn ? undefined : 1}
>
{card.caption}
</Text>
) : null}
</View>
{/* A command never steers: its Send shows only while the agent is idle. */}
{card.waitsForAgent ? null : (
{card.waitsForAgent || card.runsOnItsOwn ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || steerHeld }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const HOST_SUPPORT = {
questionAnswers: true,
queuedMessages: true,
queuedCommands: false,
queuedClear: false,
statusFeed: true,
quietRepeatedStop: true
}
Expand Down
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_CLEAR_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
Expand All @@ -16,6 +17,7 @@ describe('structuredAgentSessionHostSupport', () => {
questionAnswers: false,
queuedMessages: false,
queuedCommands: false,
queuedClear: false,
quietRepeatedStop: false,
statusFeed: false
}
Expand All @@ -32,6 +34,9 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY])
).toEqual({ ...none, queuedCommands: true })
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_CLEAR_RUNTIME_CAPABILITY])
).toEqual({ ...none, queuedClear: 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_CLEAR_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
Expand All @@ -15,6 +16,8 @@ export type StructuredAgentSessionHostSupport = {
queuedMessages: boolean
/** A /compact sent while the agent works waits as a card; an older host refuses it. */
queuedCommands: boolean
/** The same for a /clear, which the host runs itself when its card's turn comes. */
queuedClear: 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 @@ -30,6 +33,7 @@ export function structuredAgentSessionHostSupport(
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),
queuedClear: capabilities.includes(AGENT_SESSION_QUEUED_CLEAR_RUNTIME_CAPABILITY),
statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY),
quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY)
}
Expand Down
24 changes: 23 additions & 1 deletion mobile/src/session/mobile-structured-composer-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,32 @@ describe('mobile structured conversation commands', () => {
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.
// A host that holds only /compact: the busy check still answers a /clear.
expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('rejected')
expect(sendRequest).toHaveBeenCalledOnce()
})
it('a /clear the host holds in line asks to wait the same way, and its card is the answer', async () => {
const { input, sendRequest } = setup()
sendRequest.mockResolvedValueOnce({
ok: true,
result: {
ok: true,
value: {
command: 'clear',
state: 'completed',
queued: { messageId: 'op', position: 1, state: 'waiting' }
}
}
})
input.busy = () => 'working'
input.waitsInLine = () => true
expect(await dispatchMobileStructuredCommand({ ...input, text: '/clear' })).toBe('accepted')
expect(requestFields(sendRequest.mock.calls[0])).toMatchObject({
command: 'clear',
delivery: 'queue-if-active'
})
expect(input.onError).not.toHaveBeenCalled()
})
it('a /clear while the agent works says so in plain words', async () => {
const { input, sendRequest } = setup()
input.busy = () => 'working'
Expand Down
48 changes: 48 additions & 0 deletions mobile/src/session/mobile-structured-queued-message-cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,54 @@ describe('mobileQueuedMessageCards', () => {
).toMatchObject({ command: true, waitsForAgent: true })
})

const clearDraft = (overrides: Partial<AgentSessionQueuedMessage> = {}) =>
draft({
messageId: 'c',
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: '/clear' }],
command: { name: 'clear' }
},
...overrides
})

it('a /clear the host names as next offers no Send: the queue is about to run it', () => {
const idle = { pendingPrompt: false }
expect(
mobileQueuedMessageCards([clearDraft()], [], { ...idle, nextQueuedMessageId: 'c' })[0]
).toMatchObject({ runsOnItsOwn: true })
// Held with nothing published (a reopen holds it until the next turn): Send stays, as on /compact.
expect(mobileQueuedMessageCards([clearDraft()], [], idle)[0]?.runsOnItsOwn).toBeUndefined()
})

it("a /clear the host holds on background tasks says so; the strip's Stop is named when offered", () => {
const wait = { messageId: 'c', reason: 'background-tasks' as const }
const facts = { pendingPrompt: false, nextQueuedMessageWait: wait }
const [first, second] = mobileQueuedMessageCards(
[clearDraft(), draft({ messageId: 'm', position: 2 })],
[],
facts
)
expect(first).toMatchObject({
caption: 'Waiting for background tasks to finish',
runsOnItsOwn: true
})
expect(second?.caption).toBeNull()
expect(second?.runsOnItsOwn).toBeUndefined()
expect(
mobileQueuedMessageCards([clearDraft()], [], { ...facts, backgroundTasksStoppable: true })[0]
?.caption
).toBe('Waiting for background tasks to finish. Stop them to clear now.')
// A handoff wait offers no Send either, with nothing more to say.
expect(
mobileQueuedMessageCards([clearDraft()], [], {
pendingPrompt: false,
nextQueuedMessageWait: { messageId: 'c', reason: 'handoff' }
})[0]
).toMatchObject({ caption: null, runsOnItsOwn: true })
})

it("a send-failed command card's caption names Send only when Send is there", () => {
const failed = draft({
messageId: 'c',
Expand Down
33 changes: 26 additions & 7 deletions mobile/src/session/mobile-structured-queued-message-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/s
import {
QUEUED_MESSAGE_PAUSED_SEND_FAILED,
type AgentSessionQueuedMessage,
type AgentSessionQueuePause
type AgentSessionQueuePause,
type AgentSessionQueueWait
} from '../../../src/shared/agent-session-wire'
import { readAgentMessageSource } from '../../../src/shared/agent-session-message-source'
import { agentMessageAttribution } from './mobile-agent-message-attribution'
Expand All @@ -38,6 +39,9 @@ export type MobileQueuedMessageCard = {
command?: true
/** A command card while the agent works: it offers no send until the agent is idle. */
waitsForAgent?: true
/** A /clear the host names as next, or holds until a wait ends: it runs without a press, so it
* offers no Send. */
runsOnItsOwn?: true
}

function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string {
Expand Down Expand Up @@ -115,6 +119,11 @@ export function mobileQueuedMessageCards(
pendingPrompt: boolean
queuePaused?: boolean
agentWorking?: boolean
/** The host's next card, and the /clear it holds while a wait ends; read, never re-picked. */
nextQueuedMessageId?: string | null
nextQueuedMessageWait?: AgentSessionQueueWait | null
/** The strip offers a Stop for the background tasks that /clear waits on. */
backgroundTasksStoppable?: boolean
agentName?: string
statedFailures?: readonly AgentSessionFailureFact[]
}
Expand All @@ -129,13 +138,15 @@ export function mobileQueuedMessageCards(
: []
)
)
const shown = queuedMessages.filter(
(draft) => draft.state === 'returned' || !handedOff.has(draft.messageId)
)
const wait = facts.nextQueuedMessageWait ?? null
let behindReturned = false
const cards: MobileQueuedMessageCard[] = []
for (const draft of queuedMessages) {
if (draft.state !== 'returned' && handedOff.has(draft.messageId)) {
continue
}
for (const draft of shown) {
const paused = draft.paused === true
const held = wait?.messageId === draft.messageId
const caption =
draft.state === 'returned'
? returnedCaption(draft, facts.agentName, facts.statedFailures)
Expand All @@ -151,7 +162,14 @@ export function mobileQueuedMessageCards(
null
: facts.pendingPrompt
? 'Waiting for your answer'
: null
: held && wait.reason === 'background-tasks'
? facts.backgroundTasksStoppable
? 'Waiting for background tasks to finish. Stop them to clear now.'
: 'Waiting for background tasks to finish'
: null
const runsOnItsOwn =
draft.body.command?.name === 'clear' &&
(held || draft.messageId === facts.nextQueuedMessageId)
cards.push({
messageId: draft.messageId,
text: queuedMessageBodyText(draft.body),
Expand All @@ -165,7 +183,8 @@ export function mobileQueuedMessageCards(
...(draft.body.command !== undefined
? {
command: true as const,
...(facts.agentWorking ? { waitsForAgent: true as const } : {})
...(facts.agentWorking ? { waitsForAgent: true as const } : {}),
...(runsOnItsOwn ? { runsOnItsOwn: true as const } : {})
}
: {})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentChildWorkView } from '../../../src/shared/agent-status-child-work-view'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type {
AgentSessionQueuedMessage,
AgentSessionSubscribeEvent
} from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import {
backgroundTaskRowMeta,
Expand Down Expand Up @@ -41,8 +44,11 @@ function childView(id: string): AgentChildWorkView {
}
}

function withChildren(hostNow?: number): AgentSessionSubscribeEvent {
const snapshot = snapshotEvent()
function withChildren(
hostNow?: number,
queuedMessages?: AgentSessionQueuedMessage[]
): AgentSessionSubscribeEvent {
const snapshot = snapshotEvent(queuedMessages ? { queuedMessages } : undefined)
return snapshot.type === 'snapshot'
? {
...snapshot,
Expand Down Expand Up @@ -169,6 +175,40 @@ describe('mobile structured session background tasks', () => {
clock.mockRestore()
})

it('a /clear the host holds on the tasks once the turn ended says so, with no Send', async () => {
const card = (messageId: string, text: string, position: number) => ({
messageId,
position,
state: 'waiting' as const,
body: {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text }],
...(text === '/clear' ? { command: { name: 'clear' as const } } : {})
}
})
const opening = withChildren(undefined, [
card('clear-1', '/clear', 1),
card('after', 'After', 2)
])
await mount(
opening.type === 'snapshot'
? {
...opening,
nextQueuedMessageWait: { messageId: 'clear-1', reason: 'background-tasks' }
}
: opening
)
expect(hook?.isWorking).toBe(false)
// The strip offers each child its own Stop, so the caption names it.
expect(hook?.queued.cards[0]).toMatchObject({
messageId: 'clear-1',
caption: 'Waiting for background tasks to finish. Stop them to clear now.',
runsOnItsOwn: true
})
expect(hook?.queued.cards[1]?.caption).toBeNull()
})

it("stops one child through the host's background-task cancel", async () => {
await mount(withChildren())
await act(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function Harness({
questionAnswers: questionAnswersSupported,
queuedMessages: false,
queuedCommands: false,
queuedClear: false,
statusFeed: false,
quietRepeatedStop: false
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = {
questionAnswers: false,
queuedMessages: true,
queuedCommands: false,
queuedClear: false,
statusFeed: false,
quietRepeatedStop: false
}
Expand Down
8 changes: 7 additions & 1 deletion mobile/src/session/use-mobile-structured-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
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'
import { structuredSessionBackgroundTasksOfferStop } from '../../../src/shared/structured-session-background-tasks-view'

export function useMobileStructuredAgentSession(args: {
client: RpcClient | null
Expand Down Expand Up @@ -71,8 +72,9 @@ 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.
// A command waits in line only where its card renders.
const commandsWait = queueCapable && hostSupport?.queuedCommands === true
const clearWaits = queueCapable && hostSupport?.queuedClear === true
const promptCancelSupported = hostSupport?.promptCancel ?? null
const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null
const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId])
Expand Down Expand Up @@ -119,6 +121,7 @@ export function useMobileStructuredAgentSession(args: {
enabled,
queueCapable,
commandsWait,
clearWaits,
stateRef,
commandPending: commandPendingRef,
controller: sendController,
Expand Down Expand Up @@ -221,6 +224,9 @@ export function useMobileStructuredAgentSession(args: {
submissions: state.submissions,
pendingPrompt: approvalPrompt !== null || questionPrompt !== null,
agentWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence),
nextQueuedMessageId: state.nextQueuedMessageId ?? null,
nextQueuedMessageWait: state.nextQueuedMessageWait ?? null,
backgroundTasksStoppable: structuredSessionBackgroundTasksOfferStop(backgroundTasks.view),
mutate,
appendComposerText,
onSendError,
Expand Down
Loading
Loading