fix(worker): surface nonzero-exit agent turns as failed even when text streamed
This commit is contained in:
@@ -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 };
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import type { OpenCodeSession } from './opencode-session';
|
|||||||
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import { getAdapter } from './runtime/agent-runtime';
|
import { getAdapter } from './runtime/agent-runtime';
|
||||||
|
import { resolveTurnOutcome } from './runtime/turn-outcome';
|
||||||
import './runtime/register';
|
import './runtime/register';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
@@ -1426,23 +1427,35 @@ export const sendWorkspaceMessage = async (
|
|||||||
if (result.sessionId) {
|
if (result.sessionId) {
|
||||||
await persistRuntimeSession(workspace, result.sessionId);
|
await persistRuntimeSession(workspace, result.sessionId);
|
||||||
}
|
}
|
||||||
if (!assistantContent.value.trim() && result.finalMessage) {
|
const recoveredText = result.finalMessage
|
||||||
assistantContent.value = truncate(redact(result.finalMessage), 40_000);
|
? 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({
|
await updateMessage({
|
||||||
messageId,
|
messageId,
|
||||||
content: assistantContent.value,
|
content: assistantContent.value,
|
||||||
status: 'streaming',
|
status: 'streaming',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!assistantContent.value.trim()) {
|
// A hard failure (nonzero exit / failure event) surfaces the turn as
|
||||||
console.error(
|
// failed even when partial assistant text streamed — the catch path marks
|
||||||
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
|
// the message failed and appends the error event.
|
||||||
);
|
if (outcome.failure) {
|
||||||
throw new Error(
|
if (!assistantContent.value.trim()) {
|
||||||
result.error
|
console.error(
|
||||||
? `${workspace.runtime} failed:\n${result.error}`
|
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
|
||||||
: 'Codex completed without producing an assistant response.',
|
);
|
||||||
);
|
}
|
||||||
|
throw new Error(outcome.failure);
|
||||||
}
|
}
|
||||||
await updateMessage({
|
await updateMessage({
|
||||||
messageId,
|
messageId,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user