Files
orca/src/shared/structured-agent-question-projection.test.ts
T
Kelvin Amoaba 507d0a9122 fix(mobile): show answered approvals and questions as receipts, and a waiting question as "Awaiting user input" (#28906)
* fix(mobile): draw answered approvals and questions as receipts, and a pending question as its ask row

The phone projected a structured chat's journal with the host's plain
projection: an answered approval read "Allow Bash? {json} resolved", an
answered question listed its choices without the answer, and every
AskUserQuestion call stayed a raw tool line, also while its card was open
above the composer.

Desktop's question projection and receipt rules move to src/shared
(structured-agent-question-projection, native-chat-resolution-receipt,
with the receipt subject and answer heading and their words). The phone's
structured session now projects through them and hands each row its
receipt; new MobileNativeChatResolutionReceipt and
MobileNativeChatAwaitingInputRow draw them as desktop does. In a terminal
chat, a question call draws as "Awaiting user input:" until answered and
"Asked:" after.

* fix(mobile): say a question was asked, not awaited, once its agent has ended

A structured chat's question stays pending in the journal after the agent
that asked it ends (a restart, a crash, a new generation). The host stops
listing it as actionable, so the answer card goes away, but the phone's
row still read "Awaiting user input" and announced itself as live.

The session hook now returns the host's actionable prompt ids next to the
receipts, and each row asks the same shared
isActionableStructuredAgentSessionPrompt desktop's transcript row calls.
A question nobody waits on reads "Asked: …", as on desktop. The row
callback moves to its own hook so the chat view stays within its size.

* fix(mobile): decide a native chat's open question with desktop's selector

The phone decided whether a question call was still awaited with its own
rule (no answer yet and the turn working). Desktop asks the shared
selectActiveToolCall, which also reads the call's state, so a call marked
completed with no output reads "Asked" there and "Awaiting" on the phone.
Native chats now call the same selector. Terminal chats keep their rule.

* fix(mobile): zoom a receipt and its question row with the chat

Before receipts, an answered question or approval was a faint system line
that grew with the chat's pinch zoom. The receipt and the question row drew
at fixed sizes, so zooming no longer enlarged them. Both now take the
chat's zoom, through the same scaledType the notice row uses, which moves
next to the other row styles.

* fix(mobile): list a group's questions once nobody can answer them, and zoom the row's icon

A grouped prompt's row names only a count ("Asked: 2 questions"). Once it
is cancelled, or the agent that asked has ended, the answer card is gone
and the receipt lists no answers, so the phone showed the questions
nowhere. The row now lists them under its line, following desktop's
listsQuestions rule: the receipt asks for the list only when it shows no
answers, and the transcript's own question rows keep it off, as desktop's
tool run does. While the host waits, the card shows them.

The row's icon also grows with the chat's zoom, as the notice row's icon
does, so it stays level with its line.

* refactor(mobile): trim comments and simplify the question row and receipt

Comments that restated the code go. The answer heading takes the subject
its caller already has, dropping two conditions that never changed the
result, on desktop and the phone. The question row lists a group's
questions whenever its caller asks, as desktop's does, and the receipt
asks only while nobody waits. fontScale is required on both new rows.
2026-10-15 14:14:50 +00:00

234 lines
8.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from './agent-session-journal-types'
import { projectStructuredItemToNativeChat } from './structured-agent-session-projection'
import {
projectStructuredQuestionMessages,
structuredQuestionTranscript
} from './structured-agent-question-projection'
function projectQuestion(item: AgentJournalRenderItem) {
return projectStructuredQuestionMessages([item])[0]
}
const PENDING = {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
} as const
function item(itemId: string, body: AgentJournalRenderItem['body']): AgentJournalRenderItem {
return { itemId, sequence: 1, revision: 1, observedAt: 1, body }
}
describe('structured agent session ask-row projection', () => {
it('gives a pending question a row instead of dropping it from the transcript', () => {
// Codex only ever journals the question, so without this the reader sees
// nothing in the log while the agent is blocked on them.
const projected = projectQuestion(
item('q', {
kind: 'question',
question: 'Which branch?',
options: [{ id: 'q1:main', label: 'main' }],
resolution: { ...PENDING }
})
)
expect(projected?.role).toBe('system')
expect(projected?.blocks).toEqual([{ type: 'text', text: 'Which branch?' }])
})
it('prefers a grouped prompt own questions over the label naming their count', () => {
const grouped = item('grouped', {
kind: 'question',
question: '2 grouped questions from Claude',
options: [],
questions: [
{ id: 'q1', question: 'Which targets?', multiSelect: true, options: [] },
{ id: 'q2', question: 'Proceed?', multiSelect: false, options: [] }
],
resolution: { ...PENDING }
})
expect(structuredQuestionTranscript([grouped]).receipts.get('grouped')).toBe(grouped.body)
})
it('suppresses only a matching question tool and preserves failed or unmatched calls', () => {
const call = item('ask', {
kind: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which branch?' }] },
state: 'running'
})
const question = item('q', {
kind: 'question',
question: 'Which branch?',
options: [],
resolution: { ...PENDING }
})
const turn = item('turn', { kind: 'turn', turnId: 'turn', state: 'running' })
expect(projectStructuredQuestionMessages([call])).toHaveLength(1)
expect(projectStructuredQuestionMessages([turn, call, question]).map((row) => row.id)).toEqual([
'q'
])
expect(projectStructuredQuestionMessages([call, question])).toHaveLength(2)
const failed = item('failed', {
kind: 'tool-call',
name: 'AskUserQuestion',
input: call.body.kind === 'tool-call' ? call.body.input : null,
state: 'failed',
output: { head: 'Denied', byteLength: 6, truncated: false, digest: 'a' }
})
expect(
projectStructuredQuestionMessages([turn, failed, question]).map((row) => row.id)
).toEqual(['failed', 'q'])
const user = item('user', {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Next turn' }]
})
expect(projectStructuredQuestionMessages([call, user, question])).toHaveLength(3)
const nextTurn = item('next-turn', { kind: 'turn', turnId: 'next-turn', state: 'running' })
expect(projectStructuredQuestionMessages([turn, call, nextTurn, question])).toHaveLength(2)
})
it('suppresses only as many duplicate calls as question items', () => {
const turn = item('turn', { kind: 'turn', turnId: 'turn', state: 'running' })
const firstCall = item('ask-1', {
kind: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which branch?' }] },
state: 'running'
})
const secondCall = item('ask-2', {
kind: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which branch?' }] },
state: 'running'
})
const question = item('q', {
kind: 'question',
question: 'Which branch?',
options: [],
resolution: { ...PENDING }
})
expect(projectStructuredQuestionMessages([turn, firstCall, secondCall, question])).toEqual([
expect.objectContaining({ id: 'ask-2' }),
expect.objectContaining({ id: 'q' })
])
})
it('folds a settled matching tool call into its resolved question receipt', () => {
const turn = item('turn', { kind: 'turn', turnId: 'turn', state: 'completed' })
const firstCall = item('ask-1', {
kind: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which branch?' }] },
state: 'completed',
output: { head: 'main', byteLength: 4, truncated: false, digest: 'a' }
})
const secondCall = item('ask-2', {
kind: 'tool-call',
name: 'AskUserQuestion',
input: { questions: [{ question: 'Which branch?' }] },
state: 'completed',
output: { head: 'main', byteLength: 4, truncated: false, digest: 'b' }
})
const question = item('q', {
kind: 'question',
question: 'Which branch?',
options: [{ id: 'main', label: 'main' }],
resolution: { ...PENDING, state: 'resolved', selectedOptionId: 'main' }
})
expect(
projectStructuredQuestionMessages([turn, firstCall, secondCall, question]).map(
(row) => row.id
)
).toEqual(['ask-2', 'q'])
})
it('counts adjacent pending questions and preserves each independently settled answer', () => {
const first = item('q1', {
kind: 'question',
question: 'Branch?',
options: [],
resolution: { ...PENDING }
})
const second = item('q2', {
kind: 'question',
question: 'Proceed?',
options: [],
resolution: { ...PENDING }
})
const pending = structuredQuestionTranscript([first, second])
const unrelated = item('status', { kind: 'status', text: 'Background work' })
const refreshed = structuredQuestionTranscript([first, second, unrelated])
expect(refreshed.messages[0]).toBe(pending.messages[0])
expect(refreshed.receipts.get('q1')).toBe(pending.receipts.get('q1'))
expect(pending.messages.map((row) => row.id)).toEqual(['q1'])
expect(pending.receipts.get('q1')).toMatchObject({
questions: [{ question: 'Branch?' }, { question: 'Proceed?' }]
})
const resolved = item('q1', {
kind: 'question',
question: 'Branch?',
options: [{ id: 'main', label: 'main' }],
resolution: { ...PENDING, state: 'resolved', selectedOptionId: 'main' }
})
const partial = structuredQuestionTranscript([resolved, second])
expect(partial.messages.map((row) => row.id)).toEqual(['q1', 'q2'])
expect(partial.receipts.get('q1')).toBe(resolved.body)
expect(partial.receipts.get('q2')).toBe(second.body)
})
it('keeps host projection unchanged and question revisions authoritative', () => {
const pending = item('q', {
kind: 'question',
question: 'Proceed?',
options: [],
resolution: { ...PENDING }
})
expect(projectStructuredItemToNativeChat(pending)).toBeNull()
const initial = projectStructuredQuestionMessages([pending])[0]
expect(projectStructuredQuestionMessages([pending])[0]).toBe(initial)
const resolved = item('q', {
kind: 'question',
question: 'Proceed?',
options: [],
resolution: { ...PENDING, state: 'resolved' }
})
expect(projectStructuredQuestionMessages([resolved])[0]).toMatchObject({ role: 'system' })
expect(projectStructuredQuestionMessages([resolved])[0]).not.toBe(initial)
})
it('keeps an ordinary tool call', () => {
expect(
projectStructuredItemToNativeChat(
item('read', {
kind: 'tool-call',
name: 'Read',
input: { file_path: 'a.ts' },
state: 'running'
})
)?.blocks
).toHaveLength(1)
})
})
describe("a subagent's pending question keeps its producer", () => {
it('carries the linkage onto the row that stands in for the question', () => {
const projected = projectQuestion({
...item('child-q', {
kind: 'question',
question: 'Which branch?',
options: [{ id: 'q1:main', label: 'main' }],
resolution: { ...PENDING }
}),
agentId: 'task-1',
producerKind: 'agent'
})
expect(projected).toMatchObject({ role: 'system', agentId: 'task-1' })
})
})