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 }; }; // Pure decision for whether the `content` on an `assistant_completed` event // should be folded into the accumulated assistant text. // // Some runtimes (Claude `-p --output-format stream-json`, OpenCode) emit the // final answer TWICE: once as streamed `assistant_delta` chunks and again as // the `content` on the terminal `assistant_completed`/`result` event. Appending // both yields a doubled "". So only fold in the completed // content when nothing has streamed yet (a runtime that emits only a final // result). When deltas already carried the answer, skip it. The truly-empty // case is still covered by resolveTurnOutcome's recoveredText/finalMessage // fallback, so nothing is lost. export const shouldAppendCompletedContent = ( currentText: string, completedContent?: string, ): boolean => Boolean(completedContent) && !currentText.trim();