fix(agent-status): questions over 16 KB show their card with every option (#31632)

This commit is contained in:
Kelvin Amoaba
2026-10-22 00:08:35 +00:00
committed by GitHub
parent eb9d2f9a06
commit b17477614d
6 changed files with 188 additions and 58 deletions
@@ -1,4 +1,13 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
parseAgentStatusPayload
} from '../../../../shared/agent-status-types'
import {
nativeChatAskDismissKey,
parseAskFromStatus,
parseAskFromToolInput
} from '../../../../shared/native-chat-ask'
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize'
@@ -50,3 +59,33 @@ describe('mobile subagent roster bounds', () => {
expect(sanitizeNativeChatRpcBlock(own, 'runtime')).toBe(own)
})
})
describe('mobile question tool-call bounds', () => {
it('keeps a long question answerable and one dismiss identity across status and transcript', () => {
const input = {
questions: [
{
question: 'q'.repeat(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH + 4000),
options: [{ label: 'Ship it' }, { label: 'Wait' }]
}
]
}
const status = parseAgentStatusPayload(
JSON.stringify({ state: 'waiting', interactivePrompt: JSON.stringify(input) })
)
const live = parseAskFromStatus(status!.interactivePrompt, 'AskUserQuestion')
const block = sanitizeNativeChatRpcBlock(
{ type: 'tool-call', name: 'AskUserQuestion', input },
'mobile'
)
const mobile = parseAskFromToolInput(
'AskUserQuestion',
block.type === 'tool-call' ? block.input : undefined
)
const desktop = parseAskFromToolInput('AskUserQuestion', input)
expect(mobile!.questions[0].options.map((option) => option.label)).toEqual(['Ship it', 'Wait'])
expect(nativeChatAskDismissKey(mobile) === nativeChatAskDismissKey(live)).toBe(true)
expect(nativeChatAskDismissKey(desktop) === nativeChatAskDismissKey(live)).toBe(true)
})
})
@@ -2,6 +2,8 @@ import {
MAX_SUBAGENT_FIELD_CHARS,
normalizeSubagentState
} from '../../../../shared/native-chat-subagent-summary'
import { fitInteractivePrompt } from '../../../../shared/agent-status-field-normalization'
import { parseAskFromToolInput } from '../../../../shared/native-chat-ask'
import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types'
import { boundSubagentEntryId } from '../../../native-chat/subagent-entry-id-bounds'
import type { RpcContext } from '../core'
@@ -56,7 +58,7 @@ export function sanitizeNativeChatRpcBlock(
}
if (block.type === 'tool-call') {
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
return { ...block, input: sanitizeToolInput(fitAskToolInput(block), budget, 0) }
}
if (block.type === 'subagent-group') {
return {
@@ -92,6 +94,27 @@ function clipSubagentState(value: NativeChatSubagentState): NativeChatSubagentSt
return value.length > MAX_SUBAGENT_FIELD_CHARS ? normalizeSubagentState(value) : value
}
// Why: the generic budget drops every option of a long question; shorten its presentation
// text the way live status does so the transcript card keeps its labels.
function fitAskToolInput(block: Extract<NativeChatBlock, { type: 'tool-call' }>): unknown {
const { name, input } = block
if (typeof input === 'string' && input.length <= MOBILE_BLOCK_CHAR_CAP) {
return input
}
if (!parseAskFromToolInput(name, input)) {
return input
}
const encoded = typeof input === 'string' ? input : JSON.stringify(input)
if (encoded.length <= MOBILE_BLOCK_CHAR_CAP) {
return input
}
const fitted = fitInteractivePrompt(encoded, (text) => text.length <= MOBILE_BLOCK_CHAR_CAP)
if (fitted === null) {
return input
}
return typeof input === 'string' ? fitted : JSON.parse(fitted)
}
function sanitizeToolInput(
value: unknown,
budget: { remaining: number; nodes: number },
+71 -4
View File
@@ -178,11 +178,68 @@ function isEcmaTrimWhitespace(code: number): boolean {
// Why: tool/assistant fields are optional on the entry (absence = "no update
// for this field"). We only surface them when the caller actually provided a
// string value so a missing field doesn't overwrite the prior cached state.
// Why: some agents submit option labels verbatim, so only presentation-only fields may shrink.
const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([
'description',
'detail',
'header',
'question',
'summary'
])
function compactInteractivePromptValue(value: unknown, maxStringLength: number): unknown {
if (Array.isArray(value)) {
return value.map((item) => compactInteractivePromptValue(item, maxStringLength))
}
if (!value || typeof value !== 'object') {
return value
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
typeof item === 'string' && COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key)
? truncatePreservingSurrogates(item, maxStringLength)
: compactInteractivePromptValue(item, maxStringLength)
])
)
}
/** Longest form of `prompt` that `fits` accepts, or null. A JSON prompt stays valid JSON with
* every option label intact by shortening only its presentation text; other text is cut. */
export function fitInteractivePrompt(
prompt: string,
fits: (candidate: string) => boolean
): string | null {
let parsed: unknown
try {
parsed = JSON.parse(prompt)
} catch {
parsed = undefined
}
const promptAtLimit = (limit: number): string =>
parsed === undefined
? truncatePreservingSurrogates(prompt, limit)
: JSON.stringify(compactInteractivePromptValue(parsed, limit))
let low = 1
let high = prompt.length
let fitted: string | null = null
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const candidate = promptAtLimit(mid)
if (fits(candidate)) {
fitted = candidate
low = mid + 1
} else {
high = mid - 1
}
}
return fitted
}
// Why: interactivePrompt carries raw JSON (`{ questions: [...] }`) that clients
// JSON.parse to render a structured card. Unlike the other normalizers we must
// NOT trim, collapse newlines, or fold blank-line runs — any of those would
// corrupt the JSON or alter option text inside it. Only guard the length cap
// (preserving surrogate pairs) and drop empty strings to undefined.
// corrupt the JSON or alter option text inside it. Drop empty strings to undefined.
export function normalizeInteractivePromptField(
value: unknown,
maxLength: number
@@ -190,8 +247,18 @@ export function normalizeInteractivePromptField(
if (typeof value !== 'string' || value.length === 0) {
return undefined
}
const truncated = truncatePreservingSurrogates(value, maxLength)
return truncated.length > 0 ? truncated : undefined
if (value.length <= maxLength) {
return value
}
const fitted = fitInteractivePrompt(value, (candidate) => candidate.length <= maxLength)
if (fitted !== null) {
return fitted
}
// Why: labels alone over the cap keep the plain cut; readers treat unparseable JSON as capped.
console.warn(
`[agent-status] interactivePrompt of ${value.length} chars cannot fit ${maxLength} with its option labels; no card will show`
)
return truncatePreservingSurrogates(value, maxLength)
}
export function normalizeOptionalField(value: unknown, maxLength: number): string | undefined {
+20
View File
@@ -21,6 +21,7 @@ import {
ORCA_DISPATCH_PROMPT_LEAD_LINE,
ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX
} from './orca-dispatch-status-prompt'
import { parseAskFromStatus } from './native-chat-ask'
afterEach(() => {
vi.restoreAllMocks()
@@ -308,6 +309,25 @@ Fix dispatch fallback preview for normalized status prompts`
expect(result!.interactivePrompt).toHaveLength(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH)
})
it('shortens an oversized question prompt into a card that still parses with every option', () => {
const interactivePrompt = JSON.stringify({
questions: [
{
question: 'q'.repeat(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH + 4000),
header: 'Plan',
options: [{ label: 'Ship it', description: 'd'.repeat(3000) }, { label: 'Wait' }]
}
]
})
const result = parseAgentStatusPayload(JSON.stringify({ state: 'waiting', interactivePrompt }))
expect(result!.interactivePrompt!.length).toBeLessThanOrEqual(
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH
)
const ask = parseAskFromStatus(result!.interactivePrompt, 'ask_user_question')
expect(ask!.questions[0].question.startsWith('qqq')).toBe(true)
expect(ask!.questions[0].options.map((option) => option.label)).toEqual(['Ship it', 'Wait'])
})
it('leaves interactivePrompt undefined when absent or non-string', () => {
expect(parseAgentStatusPayload('{"state":"working"}')!.interactivePrompt).toBeUndefined()
expect(
+21 -1
View File
@@ -14,9 +14,29 @@ export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser }
const QUESTION_TOOL_PARSERS = new Map<string, InteractiveQuestionParser>()
// Why: live status and transcript copies of one question shorten presentation text to different
// budgets, so identity reads only a prefix every copy keeps.
const ASK_DISMISS_KEY_TEXT_PREFIX = 200
function askDismissKeyText(text: string | undefined): string | undefined {
return text?.slice(0, ASK_DISMISS_KEY_TEXT_PREFIX)
}
/** Stable cross-platform identity for one canonical question prompt. */
export function nativeChatAskDismissKey(prompt: AskPrompt | null): string | null {
return prompt ? `question:${JSON.stringify(prompt.questions)}` : null
if (!prompt) {
return null
}
const questions = prompt.questions.map((question) => ({
...question,
question: askDismissKeyText(question.question),
header: askDismissKeyText(question.header),
options: question.options.map((option) => ({
...option,
description: askDismissKeyText(option.description)
}))
}))
return `question:${JSON.stringify(questions)}`
}
export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void {
@@ -5,6 +5,7 @@ import {
type AgentHookUnavailableEnvelope,
type AgentHookRelayEnvelope
} from '../shared/agent-hook-relay'
import { fitInteractivePrompt } from '../shared/agent-status-field-normalization'
import type { RelayDispatcher } from './dispatcher'
// Why: shed the biggest, most reconstructible fields first — state/paneKey must survive or the pane
@@ -37,14 +38,6 @@ type RedeliveryState = {
}
const redeliveryByDispatcher = new WeakMap<RelayDispatcher, RedeliveryState>()
// Why: some agents submit option labels verbatim, so only presentation-only fields may shrink.
const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([
'description',
'detail',
'header',
'question',
'summary'
])
function fitsProducerFrame(dispatcher: RelayDispatcher, params: Record<string, unknown>): boolean {
return dispatcher.producerEnvelopeBudget(AGENT_HOOK_NOTIFICATION_METHOD, params) >= 0
@@ -60,23 +53,6 @@ function toNotificationParams(
: { ...params, [AGENT_HOOK_SHED_FIELDS_KEY]: [...shedFields] }
}
function compactInteractivePromptValue(value: unknown, maxStringLength: number): unknown {
if (Array.isArray(value)) {
return value.map((item) => compactInteractivePromptValue(item, maxStringLength))
}
if (!value || typeof value !== 'object') {
return value
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
typeof item === 'string' && COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key)
? item.slice(0, maxStringLength)
: compactInteractivePromptValue(item, maxStringLength)
])
)
}
function fitWaitingInteractivePrompt(
dispatcher: RelayDispatcher,
envelope: AgentHookRelayEnvelope,
@@ -86,33 +62,18 @@ function fitWaitingInteractivePrompt(
if (!prompt) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(prompt)
} catch {
parsed = undefined
}
const promptAtLimit = (limit: number): string =>
parsed === undefined
? prompt.slice(0, limit)
: JSON.stringify(compactInteractivePromptValue(parsed, limit))
let low = 1
let high = prompt.length
let fitted: AgentHookRelayEnvelope | null = null
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const candidate = {
...envelope,
payload: { ...envelope.payload, interactivePrompt: promptAtLimit(mid) }
}
if (fitsProducerFrame(dispatcher, toNotificationParams(candidate, shedFields))) {
fitted = candidate
low = mid + 1
} else {
high = mid - 1
}
}
return fitted
const fitted = fitInteractivePrompt(prompt, (interactivePrompt) =>
fitsProducerFrame(
dispatcher,
toNotificationParams(
{ ...envelope, payload: { ...envelope.payload, interactivePrompt } },
shedFields
)
)
)
return fitted === null
? null
: { ...envelope, payload: { ...envelope.payload, interactivePrompt: fitted } }
}
/** Returns the ids of the clients that rejected the frame; a rejected client is never written to. */