mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(agent-status): questions over 16 KB show their card with every option (#31632)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user