fix(desktop): share the failed-call predicate for answer-only rows
The answer-only gate re-derived the failure check the run summary and skill activity already use. Move it to fallback-model/format.ts as toolCallFailed and use it in all three places; the gate adds only the non-zero exit_code rule that mirrors the gateway's _tool_result_needs_user. The answer-only tests now drive tool.complete through the real message stream instead of upsertToolPart, and the success: false case uses a non-card tool so it can actually fail.
This commit is contained in:
@@ -18,6 +18,7 @@ import { AgentDeliveryNotice, deliveryTargetFromCommand } from '@/components/ass
|
||||
import { TimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
|
||||
import { DelegateTool } from '@/components/assistant-ui/tool/delegate'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
|
||||
import { parseMaybeObject, toolCallFailed } from '@/components/assistant-ui/tool/fallback-model'
|
||||
import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
@@ -29,7 +30,6 @@ import { generatedImageFromResult } from '@/lib/generated-images'
|
||||
import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks'
|
||||
import { isTodoToolName } from '@/lib/todos'
|
||||
import { isCardTool } from '@/lib/tool-render-class'
|
||||
import { extractToolErrorMessage } from '@/lib/tool-result-summary'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $reasoningCollapsedByDefault, $showReasoning } from '@/store/reasoning-disclosure'
|
||||
@@ -76,32 +76,15 @@ const DelegateToolPart: FC<TimelineToolCallProps> = props => {
|
||||
)
|
||||
}
|
||||
|
||||
// A failure the user still has to see: the part's isError comes from a
|
||||
// top-level payload.error the gateway's tool.complete never sets (the failure
|
||||
// sits inside result), so the same predicate the summary rows use applies —
|
||||
// with explicit success beating stale envelope errors, as in toolStatus, and
|
||||
// a non-zero exit_code counting as failure, mirroring the gateway's
|
||||
// _tool_result_needs_user (terminal reports {output, exit_code: 1, error: null}).
|
||||
const failedCallNeedsUser = ({ isError, result }: TimelineToolCallProps): boolean => {
|
||||
if (isError || result === undefined) {
|
||||
return isError === true
|
||||
}
|
||||
// A failure the user still has to see. The gateway's tool.complete carries the
|
||||
// failure inside `result`, never as the top-level error that sets isError, so
|
||||
// this reads the body like the run summary does. A non-zero exit_code counts
|
||||
// too, matching the gateway's _tool_result_needs_user, which forwards terminal
|
||||
// {output, exit_code: 1, error: null} in answer-only mode.
|
||||
const failedCallNeedsUser = (part: TimelineToolCallProps): boolean => {
|
||||
const exitCode = parseMaybeObject(part.result).exit_code
|
||||
|
||||
const record =
|
||||
typeof result === 'object' && result !== null && !Array.isArray(result)
|
||||
? (result as Record<string, unknown>)
|
||||
: undefined
|
||||
|
||||
if (record?.success === true || record?.ok === true) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
record?.success === false ||
|
||||
record?.ok === false ||
|
||||
extractToolErrorMessage(result) ||
|
||||
(typeof record?.exit_code === 'number' && record.exit_code !== 0)
|
||||
)
|
||||
return toolCallFailed(part) || (typeof exitCode === 'number' && exitCode !== 0)
|
||||
}
|
||||
|
||||
const ChainToolFallback: FC<TimelineToolCallProps> = props => {
|
||||
@@ -173,9 +156,7 @@ const ChainToolFallback: FC<TimelineToolCallProps> = props => {
|
||||
|
||||
// Answer-only: process chrome (reads, searches, commands) stays off the
|
||||
// transcript. Cards, approvals, and failed calls the user must act on remain.
|
||||
// reasoning_effort is not a display switch. The failure check reads the
|
||||
// result body too: gateway tool.complete carries failures inside `result`,
|
||||
// never as a top-level error that would set the part's isError.
|
||||
// reasoning_effort is not a display switch.
|
||||
if (!showReasoning && !failedCallNeedsUser(props) && !isCardTool(props.toolName)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type ThreadMessage } from '@assistant-ui/react'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { GatewayEvent } from '@hermes/shared'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { renderMessageStream } from '@/app/session/hooks/use-message-stream/test-harness'
|
||||
import { stubThreadEnvironment, stubThreadViewportSize, ThreadRuntime } from '@/components/assistant-ui/test-utils'
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { type GatewayEventPayload, upsertToolPart } from '@/lib/chat-messages'
|
||||
import { toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $showReasoning } from '@/store/reasoning-disclosure'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
@@ -12,6 +14,7 @@ import { $activeSessionId } from '@/store/session'
|
||||
stubThreadEnvironment()
|
||||
stubThreadViewportSize()
|
||||
|
||||
const SID = 'sess-1'
|
||||
const createdAt = new Date('2026-06-03T00:00:00.000Z')
|
||||
|
||||
function answerOnlyMessage(): ThreadMessage {
|
||||
@@ -75,46 +78,30 @@ function Harness() {
|
||||
)
|
||||
}
|
||||
|
||||
// Feed real gateway payloads through the store's event-to-part mapping
|
||||
// (upsertToolPart), the same path `handleToolEvent` drives on tool.complete —
|
||||
// instead of hand-setting isError on the part, which skips the mapping.
|
||||
function toolCompleteMessage(...payloads: GatewayEventPayload[]): ThreadMessage {
|
||||
const content = payloads.reduce(
|
||||
(acc, payload) => upsertToolPart(acc, payload, 'complete', 3),
|
||||
[] as ReturnType<typeof upsertToolPart>
|
||||
// Drive the gateway's own tool.complete event through the message stream (the
|
||||
// store Desktop renders from), then render what it produced. Answer-only
|
||||
// suppresses tool.start, so the completion arrives on its own, and its failure
|
||||
// sits inside `result`: nothing hand-sets isError on the part.
|
||||
function completionHarness(payload: Record<string, unknown>) {
|
||||
const stream = renderMessageStream(SID)
|
||||
|
||||
const send = (type: GatewayEvent['type'], body: Record<string, unknown> = {}) =>
|
||||
act(() => stream.handleEvent({ payload: body, session_id: SID, type }))
|
||||
|
||||
send('message.start')
|
||||
send('tool.complete', payload)
|
||||
send('message.complete', { text: 'done' })
|
||||
|
||||
return (
|
||||
<ThreadRuntime messages={stream.state(SID).messages.map(toRuntimeMessage)}>
|
||||
<Thread />
|
||||
</ThreadRuntime>
|
||||
)
|
||||
|
||||
return {
|
||||
id: 'assistant-tool-complete',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
...content,
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done'
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as unknown as ThreadMessage
|
||||
}
|
||||
|
||||
const completionHarness = (payload: GatewayEventPayload) => (
|
||||
<ThreadRuntime messages={[toolCompleteMessage(payload)]}>
|
||||
<Thread />
|
||||
</ThreadRuntime>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set('sess-1')
|
||||
$activeSessionId.set(SID)
|
||||
$showReasoning.set(true)
|
||||
})
|
||||
|
||||
@@ -128,7 +115,7 @@ afterEach(() => {
|
||||
describe('answer-only display policy', () => {
|
||||
it('hides reasoning and non-essential tool chrome without requiring reasoning_effort none', async () => {
|
||||
$showReasoning.set(false)
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: SID })
|
||||
|
||||
const { container } = render(<Harness />)
|
||||
|
||||
@@ -186,9 +173,10 @@ describe('answer-only display policy', () => {
|
||||
|
||||
const { container } = render(
|
||||
completionHarness({
|
||||
name: 'write_file',
|
||||
tool_id: 'write-fail-1',
|
||||
args: { path: '/repo/out.txt' },
|
||||
// Not a card tool: file edits stay visible anyway, so they can't prove the failure path.
|
||||
name: 'web_extract',
|
||||
tool_id: 'extract-fail-1',
|
||||
args: { urls: ['https://example.test'] },
|
||||
result: { success: false }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { extractToolErrorMessage } from '@/lib/tool-result-summary'
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
@@ -103,6 +105,18 @@ export function parseMaybeObject(value: unknown): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** A call that reported failure, from the part's flag or its result body.
|
||||
* Explicit success beats stale envelope errors, as in individual rows. */
|
||||
export function toolCallFailed(part: { isError?: boolean; result?: unknown }): boolean {
|
||||
const result = parseMaybeObject(part.result)
|
||||
|
||||
return (
|
||||
result.success !== true &&
|
||||
result.ok !== true &&
|
||||
Boolean(part.isError || result.success === false || result.ok === false || extractToolErrorMessage(part.result))
|
||||
)
|
||||
}
|
||||
|
||||
export function unwrapToolPayload(value: unknown): unknown {
|
||||
const record = parseMaybeObject(value)
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { translateNow } from '@/i18n'
|
||||
import { summarizeShellCommand } from '@/lib/summarize-command'
|
||||
import { firstStringField } from '@/lib/text'
|
||||
import { extractToolErrorMessage } from '@/lib/tool-result-summary'
|
||||
|
||||
import { fileEditBasename, isFileEditTool, parseMaybeObject } from './fallback-model'
|
||||
import { fileEditBasename, isFileEditTool, parseMaybeObject, toolCallFailed } from './fallback-model'
|
||||
import { skillActivityTitle } from './skill-activity'
|
||||
|
||||
/**
|
||||
@@ -171,16 +170,7 @@ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean):
|
||||
return group ? [clause(category, group, category === liveCategory)] : []
|
||||
})
|
||||
|
||||
const failed = tools.filter(tool => {
|
||||
const result = parseMaybeObject(tool.result)
|
||||
|
||||
// Explicit success beats stale envelope errors, as in individual rows.
|
||||
return (
|
||||
result.success !== true &&
|
||||
result.ok !== true &&
|
||||
Boolean(tool.isError || result.success === false || result.ok === false || extractToolErrorMessage(tool.result))
|
||||
)
|
||||
}).length
|
||||
const failed = tools.filter(toolCallFailed).length
|
||||
|
||||
if (failed) {
|
||||
clauses.push(translateNow('assistant.tool.failedCalls', failed))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { translateNow } from '@/i18n'
|
||||
import { firstStringField } from '@/lib/text'
|
||||
import { extractToolErrorMessage } from '@/lib/tool-result-summary'
|
||||
|
||||
import { parseMaybeObject } from './fallback-model/format'
|
||||
import { parseMaybeObject, toolCallFailed } from './fallback-model/format'
|
||||
|
||||
interface SkillCall {
|
||||
args?: unknown
|
||||
@@ -20,13 +19,7 @@ export function skillActivityTitle(part: SkillCall, live = true): string | undef
|
||||
}
|
||||
|
||||
const args = parseMaybeObject(part.args)
|
||||
const result = parseMaybeObject(part.result)
|
||||
|
||||
const failed =
|
||||
result.success !== true &&
|
||||
result.ok !== true &&
|
||||
Boolean(part.isError || extractToolErrorMessage(part.result) || result.success === false || result.ok === false)
|
||||
|
||||
const failed = toolCallFailed(part)
|
||||
const pending = live && part.result === undefined && part.completedAt === undefined
|
||||
const missing = !pending && part.result === undefined
|
||||
const file = firstStringField(args, ['file_path'])
|
||||
|
||||
Reference in New Issue
Block a user