fix(worker): surface nonzero-exit agent turns as failed even when text streamed

This commit is contained in:
Gabriel Brown
2026-07-11 10:07:14 -04:00
parent e5bba720b4
commit 949a036d13
3 changed files with 132 additions and 11 deletions
@@ -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 };
};
+24 -11
View File
@@ -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,