Skip to content

Commit 3528e67

Browse files
authored
Fix persisted run error rendering (#4573)
Fixes run error rendering in the agents timeline so handler failures stay attached to the failed run after a refresh. Long or multiline error messages now render using the same expandable event-card pattern as tool calls. ## Root Cause Handler failures were written to the entity `errors` collection without a `run_id`: ```ts entityStateSchema.errors.insert({ value: { error_code: errCode, message: errMsg, }, }) ``` The live UI could briefly surface the failure while the failed run was still in memory, but after a refresh the run row only reads errors associated with that run. Without `run_id`, the error became a standalone stream error and disappeared from the failed run’s footer. Separately, the UI rendered the full error text inline. Provider/runtime errors can be long or multiline, so the footer could truncate important details with no way to expand them. ## Approach - Snapshot the set of run keys before invoking each handler pass. - If the handler fails, find the last run created during that pass and write the handler error with that run’s `run_id`. - Reuse `InlineEventCard` for long or multiline run errors so they follow the same expandable pattern as tool calls: - short errors stay inline and truncated in the response metadata row - long or multiline errors render as a collapsible `run error` card with the full message in the card body ## Key Invariants - A handler error should attach only to a run created during the same handler pass. - Existing historical runs must not receive errors from later failed handler passes. - Standalone/non-run errors should remain standalone when no run was created. - Runtime-created run rows are appended sequentially, so the last newly-created run is the run to associate with a handler failure. - The UI should preserve full error text while keeping the collapsed timeline readable. ## Non-goals - This does not change standalone entity error rows in the timeline. - This does not change model provider error classification or error-code generation. - This does not introduce a second expandable styling system for run errors. ## Trade-offs The runtime infers the failed run by comparing the pre-handler run-key snapshot to the post-failure run collection, rather than threading a current run id through every handler/agent path. That keeps the change localized to `processWake` and preserves existing agent/run APIs, at the cost of selecting the last newly-created run when a handler creates multiple runs before failing. Using `InlineEventCard` makes long run errors visually heavier than a small inline footer, but it reuses the existing expandable UI pattern instead of adding bespoke details styling. ## Verification ```bash pnpm install pnpm --filter @electric-ax/agents-runtime exec vitest run test/process-wake.test.ts --reporter=dot pnpm --filter @electric-ax/agents-runtime build pnpm --filter @electric-ax/agents-server-ui run typecheck GITHUB_BASE_REF=main node scripts/check-changeset.mjs ``` ## Files changed - `packages/agents-runtime/src/process-wake.ts` - Tracks runs created during each handler pass and attaches handler errors to the last new run. - `packages/agents-server-ui/src/components/AgentResponse.tsx` - Adds shared run error rendering for both live and cached agent responses. - Uses `InlineEventCard` for long or multiline error messages. - `.changeset/run-error-rendering.md` - Adds patch changeset entries for the affected agents packages.
1 parent d8af425 commit 3528e67

3 files changed

Lines changed: 69 additions & 9 deletions

File tree

.changeset/run-error-rendering.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@electric-ax/agents-runtime': patch
3+
'@electric-ax/agents-server-ui': patch
4+
---
5+
6+
Persist handler errors on the run that produced them and render long run error messages in the agents UI as expandable details.

packages/agents-runtime/src/process-wake.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ function toError(err: unknown): Error {
146146
return err instanceof Error ? err : new Error(String(err))
147147
}
148148

149+
async function latestNewRunKey(
150+
db: EntityStreamDBWithActions,
151+
existingRunKeys: ReadonlySet<string>
152+
): Promise<string | undefined> {
153+
const runs = await queryOnce((q) => q.from({ runs: db.collections.runs }))
154+
return runs.filter((run) => !existingRunKeys.has(run.key)).at(-1)?.key
155+
}
156+
149157
async function resolveHeadersProvider(
150158
provider: ProcessWakeConfig[`claimHeaders`]
151159
): Promise<Record<string, string> | undefined> {
@@ -2158,6 +2166,9 @@ export async function processWake(
21582166
})
21592167

21602168
let sleepRequested = false
2169+
const existingRunKeys = new Set(
2170+
db.collections.runs.toArray.map((run) => run.key)
2171+
)
21612172

21622173
try {
21632174
await wirePendingSharedStates()
@@ -2208,12 +2219,14 @@ export async function processWake(
22082219
? setupErr.code
22092220
: `HANDLER_FAILED`
22102221
log.error(`handler failed for ${entityUrl}:`, errMsg)
2222+
const failedRunKey = await latestNewRunKey(db, existingRunKeys)
22112223
writeEvent(
22122224
entityStateSchema.errors.insert({
22132225
key: `error-${epoch}-${crypto.randomUUID()}`,
22142226
value: {
22152227
error_code: errCode,
22162228
message: errMsg,
2229+
...(failedRunKey ? { run_id: failedRunKey } : {}),
22172230
} as never,
22182231
}) as ChangeEvent
22192232
)

packages/agents-server-ui/src/components/AgentResponse.tsx

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Check, Copy, GitFork, Reply } from 'lucide-react'
1+
import { Check, Copy, CircleAlert, GitFork, Reply } from 'lucide-react'
22
import {
33
memo,
44
useCallback,
@@ -24,6 +24,7 @@ import {
2424
} from '../lib/streamdownConfig'
2525
import { Icon, IconButton, Stack, Text, Tooltip } from '../ui'
2626
import { ToolCallView } from './ToolCallView'
27+
import { InlineEventCard } from './InlineEventCard'
2728
import { TimeText } from './TimeText'
2829
import { ThinkingIndicator } from './ThinkingIndicator'
2930
import { ElapsedTime } from './ElapsedTime'
@@ -32,6 +33,7 @@ import { TokenUsage } from './TokenUsage'
3233

3334
import { formatElapsedDuration, toMillis } from '../lib/formatTime'
3435
import styles from './AgentResponse.module.css'
36+
import toolBlock from './toolBlock.module.css'
3537
import type { ForkFromHereAction } from './UserMessage'
3638
import type {
3739
EntityTimelineContentItem,
@@ -373,6 +375,41 @@ function errorText(errors: Array<EntityTimelineErrorItem>): string | undefined {
373375
return errors.length > 0 ? errors.map(formatError).join(`; `) : undefined
374376
}
375377

378+
const RUN_ERROR_SUMMARY_LENGTH = 180
379+
380+
function isLongRunError(message: string): boolean {
381+
return message.length > RUN_ERROR_SUMMARY_LENGTH || message.includes(`\n`)
382+
}
383+
384+
function runErrorSummary(message: string): string {
385+
const normalized = message.replace(/\s+/g, ` `)
386+
return normalized.length > RUN_ERROR_SUMMARY_LENGTH
387+
? `${normalized.slice(0, RUN_ERROR_SUMMARY_LENGTH)}…`
388+
: normalized
389+
}
390+
391+
function RunErrorInline({ message }: { message: string }): React.ReactElement {
392+
return (
393+
<Text size={1} tone="danger" truncate>
394+
{message}
395+
</Text>
396+
)
397+
}
398+
399+
function RunErrorCard({ message }: { message: string }): React.ReactElement {
400+
return (
401+
<InlineEventCard
402+
icon={CircleAlert}
403+
title="run error"
404+
summary={runErrorSummary(message)}
405+
defaultExpanded={false}
406+
headerSurface
407+
>
408+
<pre className={toolBlock.codeBlock}>{message}</pre>
409+
</InlineEventCard>
410+
)
411+
}
412+
376413
function failedRunText(
377414
run: EntityTimelineRunRow,
378415
items: Array<EntityTimelineRunItem>
@@ -658,6 +695,10 @@ export const AgentResponseLive = memo(function AgentResponseLive({
658695
)
659696
})}
660697

698+
{failureText && isLongRunError(failureText) && (
699+
<RunErrorCard message={failureText} />
700+
)}
701+
661702
<Stack align="center" gap={2} className={styles.metaRow}>
662703
{showThinking && <ThinkingIndicator />}
663704
{done && (
@@ -667,10 +708,8 @@ export const AgentResponseLive = memo(function AgentResponseLive({
667708
: `✓ done`}
668709
</Text>
669710
)}
670-
{failureText && (
671-
<Text size={1} tone="danger">
672-
{failureText}
673-
</Text>
711+
{failureText && !isLongRunError(failureText) && (
712+
<RunErrorInline message={failureText} />
674713
)}
675714
{/* Elapsed-time ticker — visible while the response is still
676715
in flight so the user can see how long the model has been
@@ -896,6 +935,10 @@ export const AgentResponse = memo(function AgentResponse({
896935
return <ToolCallView key={item.toolCallId} item={item} />
897936
})}
898937

938+
{section.error && isLongRunError(section.error) && (
939+
<RunErrorCard message={section.error} />
940+
)}
941+
899942
<Stack align="center" gap={2} className={styles.metaRow}>
900943
{showThinking && <ThinkingIndicator />}
901944
{section.done && (
@@ -905,10 +948,8 @@ export const AgentResponse = memo(function AgentResponse({
905948
: `✓ done`}
906949
</Text>
907950
)}
908-
{section.error && (
909-
<Text size={1} tone="danger">
910-
{section.error}
911-
</Text>
951+
{section.error && !isLongRunError(section.error) && (
952+
<RunErrorInline message={section.error} />
912953
)}
913954
{/* Elapsed-time ticker — kept in sync with the live variant
914955
above so cached sections (rare during streaming, but the

0 commit comments

Comments
 (0)