diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts index 2817ffc0dc0..eabc36c5551 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts index a231c620581..47a857c30c6 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts @@ -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): 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 }, diff --git a/src/shared/agent-status-field-normalization.ts b/src/shared/agent-status-field-normalization.ts index 4fb741b5ec1..ab4a138475d 100644 --- a/src/shared/agent-status-field-normalization.ts +++ b/src/shared/agent-status-field-normalization.ts @@ -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 { diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 59bbfff318a..5af86b39193 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -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( diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index 4151aa9bdbe..f9313b2f7ec 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -14,9 +14,29 @@ export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } const QUESTION_TOOL_PARSERS = new Map() +// 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 { diff --git a/src/wsl-guest/agent-hook-envelope-publication.ts b/src/wsl-guest/agent-hook-envelope-publication.ts index 684ad8f7dde..77c94e6c061 100644 --- a/src/wsl-guest/agent-hook-envelope-publication.ts +++ b/src/wsl-guest/agent-hook-envelope-publication.ts @@ -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() -// 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): 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. */