diff --git a/apps/agent-worker/src/runtime/turn-outcome.ts b/apps/agent-worker/src/runtime/turn-outcome.ts new file mode 100644 index 0000000..44e689e --- /dev/null +++ b/apps/agent-worker/src/runtime/turn-outcome.ts @@ -0,0 +1,44 @@ +import type { AgentRuntimeName } from './agent-runtime'; + +// Pure, side-effect-free decision for how a completed runtime turn should be +// surfaced. Kept out of worker.ts so it is unit-testable without loading the +// worker's Convex/Docker dependencies. +// +// Inputs: +// - assistantText: text already streamed to the user (may be partial). +// - recoveredText: text recovered from `TurnResult.finalMessage` (already +// redacted+truncated by the caller), used only when nothing streamed. +// - error: `TurnResult.error` — non-empty when the runtime reported a hard +// failure (nonzero exit / failure event). +// - runtime: runtime name, for the failure message prefix. +// +// Output: +// - text: the assistant text to persist (streamed text, else recovered text). +// - failure: when set, the caller must surface the turn as FAILED (throw). The +// partial text in `text` is still preserved so the user can see it. +export const resolveTurnOutcome = (args: { + assistantText: string; + recoveredText?: string; + error?: string; + runtime: AgentRuntimeName; +}): { text: string; failure?: string } => { + const { assistantText, recoveredText, error, runtime } = args; + let text = assistantText; + if (!text.trim() && recoveredText) { + text = recoveredText; + } + if (!text.trim()) { + return { + text, + failure: error + ? `${runtime} failed:\n${error}` + : 'Codex completed without producing an assistant response.', + }; + } + // A hard failure that streamed partial text must still be surfaced as failed; + // the streamed text is preserved above so the user sees the truncated answer. + if (error) { + return { text, failure: `${runtime} failed:\n${error}` }; + } + return { text }; +}; diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index b8e5c7b..b2bcbd8 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -19,6 +19,7 @@ import type { OpenCodeSession } from './opencode-session'; import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { BoxHandle } from './user-container'; import { getAdapter } from './runtime/agent-runtime'; +import { resolveTurnOutcome } from './runtime/turn-outcome'; import './runtime/register'; import { env } from './env'; import { @@ -1426,23 +1427,35 @@ export const sendWorkspaceMessage = async ( if (result.sessionId) { await persistRuntimeSession(workspace, result.sessionId); } - if (!assistantContent.value.trim() && result.finalMessage) { - assistantContent.value = truncate(redact(result.finalMessage), 40_000); + const recoveredText = result.finalMessage + ? truncate(redact(result.finalMessage), 40_000) + : undefined; + const outcome = resolveTurnOutcome({ + assistantText: assistantContent.value, + recoveredText, + error: result.error, + runtime: workspace.runtime, + }); + // Persist any recovered finalMessage text so the user still sees it, even + // when the turn ultimately failed with partial output. + if (outcome.text !== assistantContent.value) { + assistantContent.value = outcome.text; await updateMessage({ messageId, content: assistantContent.value, status: 'streaming', }); } - if (!assistantContent.value.trim()) { - console.error( - `Codex completed without producing an assistant response for job ${claim.job._id}.`, - ); - throw new Error( - result.error - ? `${workspace.runtime} failed:\n${result.error}` - : 'Codex completed without producing an assistant response.', - ); + // A hard failure (nonzero exit / failure event) surfaces the turn as + // failed even when partial assistant text streamed — the catch path marks + // the message failed and appends the error event. + if (outcome.failure) { + if (!assistantContent.value.trim()) { + console.error( + `Codex completed without producing an assistant response for job ${claim.job._id}.`, + ); + } + throw new Error(outcome.failure); } await updateMessage({ messageId, diff --git a/apps/agent-worker/tests/unit/turn-outcome.test.ts b/apps/agent-worker/tests/unit/turn-outcome.test.ts new file mode 100644 index 0000000..16a34ab --- /dev/null +++ b/apps/agent-worker/tests/unit/turn-outcome.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest'; + +import { resolveTurnOutcome } from '../../src/runtime/turn-outcome'; + +describe('resolveTurnOutcome', () => { + test('partial streamed text + error is surfaced as failed and text preserved', () => { + const outcome = resolveTurnOutcome({ + assistantText: 'partial answer before crash', + error: 'exit code 1: rate limited', + runtime: 'codex', + }); + expect(outcome.text).toBe('partial answer before crash'); + expect(outcome.failure).toBe('codex failed:\nexit code 1: rate limited'); + }); + + test('successful turn with streamed text and no error has no failure', () => { + const outcome = resolveTurnOutcome({ + assistantText: 'complete answer', + runtime: 'codex', + }); + expect(outcome.text).toBe('complete answer'); + expect(outcome.failure).toBeUndefined(); + }); + + test('empty turn with error is surfaced as failed', () => { + const outcome = resolveTurnOutcome({ + assistantText: ' ', + error: 'exit code 137', + runtime: 'claude', + }); + expect(outcome.failure).toBe('claude failed:\nexit code 137'); + }); + + test('empty turn with no error reports the no-response failure', () => { + const outcome = resolveTurnOutcome({ + assistantText: '', + runtime: 'codex', + }); + expect(outcome.failure).toBe( + 'Codex completed without producing an assistant response.', + ); + }); + + test('recovers finalMessage text when nothing streamed and no error', () => { + const outcome = resolveTurnOutcome({ + assistantText: '', + recoveredText: 'recovered from last-message file', + runtime: 'codex', + }); + expect(outcome.text).toBe('recovered from last-message file'); + expect(outcome.failure).toBeUndefined(); + }); + + test('recovered finalMessage text + error is still surfaced as failed', () => { + const outcome = resolveTurnOutcome({ + assistantText: '', + recoveredText: 'recovered partial', + error: 'network drop', + runtime: 'codex', + }); + expect(outcome.text).toBe('recovered partial'); + expect(outcome.failure).toBe('codex failed:\nnetwork drop'); + }); +});