mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
fix(native-chat): replace the raw question tool row with an awaiting-input row
A question tool call rendered as ordinary tool activity — "Running
AskUserQuestion" with a clipped JSON payload while live, then a "1x
AskUserQuestion {...}" run header once settled — so the one row the reader
actually has to act on read as machine output.
It now draws as "Awaiting user input: <question>", led by a comment-bubble
glyph, with the label pulsing while the answer is outstanding and reading
"Asked: <question>" once it lands. A grouped prompt names how many questions
it asks rather than quoting only the first, since one row stands for the whole
prompt. Question calls also leave the run header, so the count beside them
reports only the work that actually ran.
Codex journals only the question and never a call for it, and a pending
question was dropped from the transcript entirely — its chat log said nothing
while the agent sat blocked on the reader. Pending questions now project the
same row. Claude journals both the call and the question it raised, so the
call itself is suppressed and the one row is fed from one source.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
NATIVE_CHAT_ASK_ROW_COPY,
|
||||
type NativeChatAskRowSubject
|
||||
} from '../../../../shared/native-chat-ask-row'
|
||||
import { NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
|
||||
/**
|
||||
* The row a question tool call draws in place of its raw input. The agent is
|
||||
* blocked on the reader, so the row says that in plain words and names what was
|
||||
* asked, rather than printing the tool's name and a clipped JSON payload.
|
||||
*
|
||||
* Only the label breathes: the question is the part worth reading, and animating
|
||||
* it would make the one line the reader has to act on the hardest one to read.
|
||||
*/
|
||||
export function NativeChatAwaitingInputRow({
|
||||
subject,
|
||||
pending
|
||||
}: {
|
||||
/** Null when the payload named no question; the label carries the row alone. */
|
||||
subject: NativeChatAskRowSubject | null
|
||||
/** Still waiting on an answer; a settled prompt reports what was asked. */
|
||||
pending: boolean
|
||||
}): React.JSX.Element {
|
||||
const label = pending
|
||||
? translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting)
|
||||
: translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked)
|
||||
const text =
|
||||
subject === null
|
||||
? null
|
||||
: subject.kind === 'question'
|
||||
? subject.text
|
||||
: translate(
|
||||
'components.native-chat.ask.questionCount',
|
||||
NATIVE_CHAT_ASK_ROW_COPY.questionCount,
|
||||
{ value0: subject.count }
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-6 w-full items-center gap-1.5 py-0.5 text-sm leading-relaxed text-muted-foreground"
|
||||
data-native-chat-ask-row={pending ? 'awaiting' : 'asked'}
|
||||
aria-live={pending ? 'polite' : undefined}
|
||||
>
|
||||
<NativeChatToolRunIcon iconName="message-square-more" className="text-muted-foreground" />
|
||||
<span className={cn('shrink-0', pending && 'animate-pulse motion-reduce:animate-none')}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-foreground/85">{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Folder,
|
||||
Globe,
|
||||
ListChecks,
|
||||
MessageSquareMore,
|
||||
Pencil,
|
||||
Plug,
|
||||
Search,
|
||||
@@ -29,7 +30,8 @@ const NATIVE_CHAT_TOOL_GLYPHS: Record<NativeChatToolIconName, LucideIcon> = {
|
||||
plug: Plug,
|
||||
bot: Bot,
|
||||
'list-checks': ListChecks,
|
||||
wrench: Wrench
|
||||
wrench: Wrench,
|
||||
'message-square-more': MessageSquareMore
|
||||
}
|
||||
|
||||
/** The fixed 16px slot with a 14px glyph, which keeps every row left-aligned
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const QUESTION = 'What would you like me to do next in this repo?'
|
||||
const ASK_INPUT = { questions: [{ question: QUESTION }] }
|
||||
|
||||
function askBlocks(state: 'running' | 'completed'): NativeChatBlock[] {
|
||||
return [{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state }]
|
||||
}
|
||||
|
||||
describe('NativeChatToolRun awaiting-input row', () => {
|
||||
it('replaces a running ask call with the awaiting row', () => {
|
||||
const { container } = render(
|
||||
<NativeChatToolRun blocks={askBlocks('running')} expandSignal activeTurnIsWorking />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Awaiting user input:')).toHaveClass(
|
||||
'animate-pulse',
|
||||
'motion-reduce:animate-none'
|
||||
)
|
||||
expect(screen.getByText(QUESTION)).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-message-square-more')).toBeInTheDocument()
|
||||
// The raw call and its payload are exactly what this row exists to replace.
|
||||
expect(screen.queryByText(/Running AskUserQuestion/)).toBeNull()
|
||||
expect(screen.queryByText(/AskUserQuestion/)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a settled ask without the pulse or a tool-count header', () => {
|
||||
const { container } = render(
|
||||
<NativeChatToolRun blocks={askBlocks('completed')} expandSignal activeTurnIsWorking={false} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse')
|
||||
expect(screen.getByText(QUESTION)).toBeInTheDocument()
|
||||
// A run that is only the ask has no work left to head, so it draws no `1×`.
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('counts only the work that ran in the header beside the ask', () => {
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
|
||||
{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'running' }
|
||||
]
|
||||
|
||||
render(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking />)
|
||||
|
||||
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
|
||||
// One call ran; being asked a question is not work to count.
|
||||
expect(screen.getByText('1×')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('draws the row from the tool name when the payload names no question', () => {
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
blocks={[{ type: 'tool-call', name: 'request_user_input', input: {}, state: 'running' }]}
|
||||
expandSignal
|
||||
activeTurnIsWorking
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/request_user_input/)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -552,7 +552,7 @@ describe('NativeChatToolRun', () => {
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'AskUserQuestion',
|
||||
name: 'CreateWidget',
|
||||
input: { prompt: 'which?' },
|
||||
state: 'completed'
|
||||
}
|
||||
@@ -569,7 +569,7 @@ describe('NativeChatToolRun', () => {
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'AskUserQuestion',
|
||||
name: 'CreateWidget',
|
||||
input: { prompt: 'which?' },
|
||||
state: 'completed'
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
selectActiveToolCall
|
||||
} from '../../../../shared/native-chat-tool-activity'
|
||||
import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon'
|
||||
import {
|
||||
hasNativeChatAskCall,
|
||||
isNativeChatAskCall,
|
||||
nativeChatAskRunSubject
|
||||
} from '../../../../shared/native-chat-ask-row'
|
||||
import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow'
|
||||
import { NativeChatTaskList } from './NativeChatTaskList'
|
||||
import { buildNativeChatTaskListRows } from './native-chat-task-list-history'
|
||||
import { NativeChatSubagentRun } from './NativeChatSubagentRun'
|
||||
@@ -88,11 +94,20 @@ export function NativeChatToolRun({
|
||||
const subagentRows = subagentGroups
|
||||
.filter(isRenderableSubagentGroup)
|
||||
.map((group) => <NativeChatSubagentRun key={group.groupId} block={group} />)
|
||||
const callCount = countToolCalls(blocks) || blocks.length
|
||||
// A question tool call is not work to summarize — the agent is blocked on the
|
||||
// reader — so its calls leave the header for one awaiting row and the header
|
||||
// is left describing only what actually ran. Everything below reads
|
||||
// `headerBlocks`, so a run that is nothing but the ask draws no header at all
|
||||
// rather than a `1×` counting a call the reader is being asked to answer.
|
||||
const hasAskCall = structuredActivityUi && hasNativeChatAskCall(blocks)
|
||||
const askSubject = hasAskCall ? nativeChatAskRunSubject(blocks) : null
|
||||
const headerBlocks = hasAskCall ? blocks.filter((block) => !isNativeChatAskCall(block)) : blocks
|
||||
const showsHeader = !hasAskCall || countToolCalls(headerBlocks) > 0
|
||||
const callCount = countToolCalls(headerBlocks) || headerBlocks.length
|
||||
// Members stay separate all the way to the markup: joining them into one
|
||||
// string is what made a run read as a single call, because the separator also
|
||||
// occurs inside tool names like `browser.open` and `tools/read`.
|
||||
const summaryMembers = toolRunSummaryMembers(blocks)
|
||||
const summaryMembers = toolRunSummaryMembers(headerBlocks)
|
||||
const hiddenCallCount = Math.max(0, callCount - summaryMembers.length)
|
||||
// Same content-signature keying the member rows below use: two identical calls
|
||||
// in one run are distinguished by occurrence, never by list position.
|
||||
@@ -109,7 +124,14 @@ export function NativeChatToolRun({
|
||||
? selectActiveToolCall(blocks, { activeTurnIsWorking })
|
||||
: null
|
||||
const isSettled = latestActiveCall == null
|
||||
const hasRunningCall = blocks.some((block) => isToolCallBlock(block) && block.state === 'running')
|
||||
// The ask owns the active slot when it is the live call: its own row already
|
||||
// says the turn is waiting, and a second "Running request_user_input" beside
|
||||
// it would report the block twice in two different vocabularies.
|
||||
const askIsActive = latestActiveCall !== null && isNativeChatAskCall(latestActiveCall)
|
||||
const headerActiveCall = askIsActive ? null : latestActiveCall
|
||||
const hasRunningCall = headerBlocks.some(
|
||||
(block) => isToolCallBlock(block) && block.state === 'running'
|
||||
)
|
||||
// The turn caret opens the activity group while each child tool stays collapsed.
|
||||
const expandToolLines = expandOverride === undefined ? open : false
|
||||
// Diffing every edit is the run's most expensive work, so a collapsed run —
|
||||
@@ -135,7 +157,7 @@ export function NativeChatToolRun({
|
||||
// spans categories therefore heads with the generic tool glyph. The glyph is
|
||||
// fixed once settled, so state rides on the trailing mark — a leading glyph
|
||||
// that flipped to a check would read as a change of identity.
|
||||
const settledHeaderIcon = nativeChatToolRunIconName(blocks.filter(isToolCallBlock))
|
||||
const settledHeaderIcon = nativeChatToolRunIconName(headerBlocks.filter(isToolCallBlock))
|
||||
const fallbackLabel =
|
||||
callCount === 1
|
||||
? translate('components.native-chat.tool.countOne', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne)
|
||||
@@ -177,7 +199,10 @@ export function NativeChatToolRun({
|
||||
// so the turn's activity doesn't crowd the message text.
|
||||
<div className="mt-3">
|
||||
{subagentRows}
|
||||
{latestActiveCall ? (
|
||||
{hasAskCall ? (
|
||||
<NativeChatAwaitingInputRow subject={askSubject} pending={askIsActive} />
|
||||
) : null}
|
||||
{!showsHeader ? null : headerActiveCall ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
@@ -186,12 +211,12 @@ export function NativeChatToolRun({
|
||||
aria-live="polite"
|
||||
>
|
||||
<NativeChatToolIcon
|
||||
mcpIdentity={latestActiveCall.mcpIdentity}
|
||||
rowWord={latestActiveCall.name}
|
||||
mcpIdentity={headerActiveCall.mcpIdentity}
|
||||
rowWord={headerActiveCall.name}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<span className="min-w-0 animate-pulse truncate text-foreground/85 motion-reduce:animate-none">
|
||||
{nativeChatToolActivityLabel(latestActiveCall)}
|
||||
{nativeChatToolActivityLabel(headerActiveCall)}
|
||||
</span>
|
||||
{open ? <ChevronRight className="size-3.5 rotate-90 text-muted-foreground" /> : null}
|
||||
</button>
|
||||
@@ -267,14 +292,14 @@ export function NativeChatToolRun({
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{open ? (
|
||||
{open && showsHeader ? (
|
||||
// Members are indented under the header because nothing else marks the
|
||||
// run's extent — flush rows are indistinguishable from the blocks after
|
||||
// them, so the batch has no visible end.
|
||||
<div className="mt-1 pl-4">
|
||||
{(() => {
|
||||
const seen = new Map<string, number>()
|
||||
return blocks.map((block, blockIndex) => {
|
||||
return headerBlocks.map((block, blockIndex) => {
|
||||
const taskList = taskLists?.rows.get(block)
|
||||
if (taskList) {
|
||||
return <NativeChatTaskList key={`tasks:${blockIndex}`} {...taskList} />
|
||||
|
||||
@@ -17255,6 +17255,11 @@
|
||||
"skip": "Skip",
|
||||
"sending": "Sending…"
|
||||
},
|
||||
"ask": {
|
||||
"awaiting": "Awaiting user input:",
|
||||
"asked": "Asked:",
|
||||
"questionCount": "{{value0}} questions"
|
||||
},
|
||||
"approval": {
|
||||
"title": "Allow {{value0}}?",
|
||||
"allow": "Allow",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasNativeChatAskCall, nativeChatAskRunSubject } from './native-chat-ask-row'
|
||||
import type { NativeChatBlock } from './native-chat-types'
|
||||
|
||||
function askCall(input: unknown, name = 'AskUserQuestion'): NativeChatBlock {
|
||||
return { type: 'tool-call', name, input }
|
||||
}
|
||||
|
||||
describe('native chat ask row', () => {
|
||||
it('names the one question a prompt asks', () => {
|
||||
expect(
|
||||
nativeChatAskRunSubject([askCall({ questions: [{ question: 'Which branch?' }] })])
|
||||
).toEqual({ kind: 'question', text: 'Which branch?' })
|
||||
})
|
||||
|
||||
it('counts a grouped prompt rather than quoting only its first question', () => {
|
||||
expect(
|
||||
nativeChatAskRunSubject([
|
||||
askCall({ questions: [{ question: 'Which branch?' }, { question: 'Proceed?' }] })
|
||||
])
|
||||
).toEqual({ kind: 'count', count: 2 })
|
||||
})
|
||||
|
||||
it('aggregates the per-question calls Codex journals for a single prompt', () => {
|
||||
// Codex writes one call per question, so a per-call row would stack two
|
||||
// pulsing lines for a prompt the reader was shown once.
|
||||
expect(
|
||||
nativeChatAskRunSubject([
|
||||
askCall({ questions: [{ question: 'Which branch?' }] }, 'request_user_input'),
|
||||
askCall({ questions: [{ question: 'Proceed?' }] }, 'request_user_input')
|
||||
])
|
||||
).toEqual({ kind: 'count', count: 2 })
|
||||
})
|
||||
|
||||
it('decodes the JSON-string arguments Codex delivers', () => {
|
||||
expect(
|
||||
nativeChatAskRunSubject([
|
||||
askCall(
|
||||
JSON.stringify({ questions: [{ question: 'Which branch?' }] }),
|
||||
'request_user_input'
|
||||
)
|
||||
])
|
||||
).toEqual({ kind: 'question', text: 'Which branch?' })
|
||||
})
|
||||
|
||||
it('still reports an ask whose payload names no question', () => {
|
||||
// Decided by the tool name alone: an unreadable payload must not put the raw
|
||||
// call back on screen as the row it was meant to replace.
|
||||
const blocks = [askCall({ prompt: 'which?' })]
|
||||
|
||||
expect(hasNativeChatAskCall(blocks)).toBe(true)
|
||||
expect(nativeChatAskRunSubject(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves an ordinary tool call alone even when its input carries questions', () => {
|
||||
expect(hasNativeChatAskCall([askCall({ questions: [{ question: 'x' }] }, 'Read')])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
// The native-chat row that stands in for a question tool call. Both platform
|
||||
// UIs read this copy (desktop as its i18n fallbacks, mobile directly) so the two
|
||||
// can never describe the same pending question differently.
|
||||
|
||||
import { isAskUserQuestionTool } from './agent-question-answered-intent'
|
||||
import { parseAskFromToolInput } from './native-chat-ask'
|
||||
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
|
||||
|
||||
export const NATIVE_CHAT_ASK_ROW_COPY = {
|
||||
awaiting: 'Awaiting user input:',
|
||||
asked: 'Asked:',
|
||||
questionCount: '{{value0}} questions'
|
||||
} as const
|
||||
|
||||
/** What the row names after its label: the question itself, or how many were
|
||||
* asked. One row stands for the whole prompt, so a grouped prompt may not quote
|
||||
* just its first question as though it were the only one. */
|
||||
export type NativeChatAskRowSubject =
|
||||
| { kind: 'question'; text: string }
|
||||
| { kind: 'count'; count: number }
|
||||
|
||||
/** Whether this block is a question tool call, and so is drawn as the awaiting
|
||||
* row rather than as an ordinary tool line. */
|
||||
export function isNativeChatAskCall(block: NativeChatBlock): boolean {
|
||||
return isToolCallBlock(block) && isAskUserQuestionTool(block.name)
|
||||
}
|
||||
|
||||
/** Whether this run asks the reader anything. Decided by the tool name alone,
|
||||
* because that already says the agent is blocked on an answer — a payload this
|
||||
* cannot parse must not put the raw call back on screen as the row it replaced. */
|
||||
export function hasNativeChatAskCall(blocks: readonly NativeChatBlock[]): boolean {
|
||||
return blocks.some(isNativeChatAskCall)
|
||||
}
|
||||
|
||||
/** The questions one call names, dropping any it states blankly. */
|
||||
function askCallQuestions(block: NativeChatBlock): string[] {
|
||||
if (!isToolCallBlock(block)) {
|
||||
return []
|
||||
}
|
||||
const prompt = parseAskFromToolInput(block.name, block.input)
|
||||
return prompt
|
||||
? prompt.questions.map((question) => question.question.trim()).filter((text) => text.length > 0)
|
||||
: []
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject for the whole run's question activity, or null when nothing in it
|
||||
* names a question — the row then stands on its label alone, which still tells
|
||||
* the reader the turn is theirs to unblock.
|
||||
*
|
||||
* Aggregated across calls, not taken from one: Codex journals a separate call
|
||||
* per question of the same prompt, so a per-call row would stack three pulsing
|
||||
* lines for what the reader was asked once.
|
||||
*/
|
||||
export function nativeChatAskRunSubject(
|
||||
blocks: readonly NativeChatBlock[]
|
||||
): NativeChatAskRowSubject | null {
|
||||
const questions = blocks.filter(isNativeChatAskCall).flatMap(askCallQuestions)
|
||||
if (questions.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (questions.length > 1) {
|
||||
return { kind: 'count', count: questions.length }
|
||||
}
|
||||
const text = questions[0]
|
||||
return text ? { kind: 'question', text } : null
|
||||
}
|
||||
@@ -95,6 +95,18 @@ export function parseAskFromStatus(
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a question tool call's own input, through the same registered-parser
|
||||
* dispatch live status uses. Codex delivers arguments as a JSON string, so a
|
||||
* string input is decoded rather than treated as prose. */
|
||||
export function parseAskFromToolInput(
|
||||
toolName: string | undefined,
|
||||
input: unknown
|
||||
): AskPrompt | null {
|
||||
return typeof input === 'string'
|
||||
? parseAskFromStatus(input, toolName)
|
||||
: parseToolInput(toolName, input)
|
||||
}
|
||||
|
||||
/** Resolve the newest question tool that has not received its FIFO tool result.
|
||||
* Transcript replay parses each tool-call through the same registered-parser +
|
||||
* canonical-shape fallback as live status, so a question tool that rendered
|
||||
|
||||
@@ -40,6 +40,10 @@ export type NativeChatToolIconName =
|
||||
| 'bot'
|
||||
| 'list-checks'
|
||||
| 'wrench'
|
||||
/** The awaiting-input row's glyph. Carried here for the shared aligned slot;
|
||||
* it names no tool category, because that row stands for a question rather
|
||||
* than for the call that asked it. */
|
||||
| 'message-square-more'
|
||||
|
||||
/** Category to glyph. */
|
||||
export const NATIVE_CHAT_TOOL_ICON_NAMES: Record<NativeChatToolCategory, NativeChatToolIconName> = {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from './agent-session-journal-types'
|
||||
import { projectStructuredItemToNativeChat } from './structured-agent-session-projection'
|
||||
|
||||
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 = projectStructuredItemToNativeChat(
|
||||
item('q', {
|
||||
kind: 'question',
|
||||
question: 'Which branch?',
|
||||
options: [{ id: 'q1:main', label: 'main' }],
|
||||
resolution: { ...PENDING }
|
||||
})
|
||||
)
|
||||
|
||||
expect(projected?.role).toBe('assistant')
|
||||
expect(projected?.blocks).toEqual([
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'request_user_input',
|
||||
input: { questions: [{ question: 'Which branch?' }] },
|
||||
state: 'running'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers a grouped prompt own questions over the label naming their count', () => {
|
||||
const projected = projectStructuredItemToNativeChat(
|
||||
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(projected?.blocks).toEqual([
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'request_user_input',
|
||||
input: { questions: [{ question: 'Which targets?' }, { question: 'Proceed?' }] },
|
||||
state: 'running'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the question tool call itself so the row is not drawn twice', () => {
|
||||
// Claude journals both the `AskUserQuestion` call and the question it
|
||||
// raised; the question item above is the one that draws the row.
|
||||
expect(
|
||||
projectStructuredItemToNativeChat(
|
||||
item('ask', {
|
||||
kind: 'tool-call',
|
||||
name: 'AskUserQuestion',
|
||||
input: { questions: [{ question: 'Which branch?' }] },
|
||||
state: 'running'
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizePromptField
|
||||
} from './agent-status-field-normalization'
|
||||
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import { isAskUserQuestionTool } from './agent-question-answered-intent'
|
||||
import {
|
||||
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
|
||||
AGENT_STATUS_TOOL_NAME_MAX_LENGTH
|
||||
@@ -43,6 +44,17 @@ export function stripBoundedTextMarker(text: string): { text: string; truncated:
|
||||
return { text: stripped, truncated: stripped.length !== text.length }
|
||||
}
|
||||
|
||||
/** What a pending question asks. Claude groups several under one item and names
|
||||
* that item by their count, so its own list wins over that summary label. */
|
||||
function pendingQuestionTexts(body: {
|
||||
question: string
|
||||
questions?: readonly { question: string }[]
|
||||
}): { question: string }[] {
|
||||
return body.questions && body.questions.length > 0
|
||||
? body.questions.map((question) => ({ question: question.question }))
|
||||
: [{ question: body.question }]
|
||||
}
|
||||
|
||||
function itemBlocks(item: AgentJournalRenderItem): {
|
||||
role: NativeChatMessage['role']
|
||||
blocks: NativeChatBlock[]
|
||||
@@ -52,6 +64,12 @@ function itemBlocks(item: AgentJournalRenderItem): {
|
||||
return { role: body.role, blocks: body.blocks }
|
||||
}
|
||||
if (body.kind === 'tool-call') {
|
||||
// The question item below draws this call's row. Claude journals both the
|
||||
// `AskUserQuestion` call and the question it raised, so keeping this one too
|
||||
// would print the awaiting row twice — once per lane that saw the same ask.
|
||||
if (isAskUserQuestionTool(body.name)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
@@ -105,7 +123,21 @@ function itemBlocks(item: AgentJournalRenderItem): {
|
||||
}
|
||||
if (body.kind === 'question') {
|
||||
if (body.resolution.state === 'pending') {
|
||||
return null
|
||||
// A pending question is work the reader has to act on, so it takes a row
|
||||
// instead of living only in the docked card. Shaped as the question tool
|
||||
// call it came from, so the one awaiting-input row serves both this and
|
||||
// the lanes that journal that call directly.
|
||||
return {
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'request_user_input',
|
||||
input: { questions: pendingQuestionTexts(body) },
|
||||
state: 'running'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const choices = body.options.map((option) => option.label).join(' · ')
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user