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:
Hermes Agent
2026-09-25 10:07:18 -05:00
committed by brooklyn!
parent e0a5c07109
commit 66447b4e9f
5 changed files with 57 additions and 91 deletions

View File

@@ -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
}

View File

@@ -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 }
})
)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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'])