Ctrl K
ComunidadGuías…

agents/opencode1.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * The OpenCode V1 driver (`opencode-ai`, `@opencode-ai/sdk/v2`): how the shared
 * harness in `opencode.ts` opens a session on a V1 server, reads its event
 * stream, and runs one prompt turn. V1 reads its sandbox from
 * `OPENCODE_PERMISSION` and its gate plugin in the `tool.execute.before` shape.
 *
 * Naming note: the SDK's `/v2` subpath is the second generation of opencode's
 * HTTP API inside V1 — nothing to do with OpenCode V2, which is `opencode2.ts`.
 */
import { performance } from "node:perf_hooks";
import {
  type AssistantMessage,
  createOpencodeClient,
  type EventSubscribeResponse,
  type OpencodeClient,
  type Part,
  type SessionStatus,
  type TextPartInput,
} from "@opencode-ai/sdk/v2";
import { AGENT_ACTIVITY_TIMEOUT_MS, markActivity } from "../utils/activity.ts";
import { log } from "../utils/cli.ts";
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
import { buildOpencodeSubagentGateSource } from "./opencodePlugin.ts";
import {
  bindSubagentSession,
  createRunState,
  declaredRung,
  emptyTurn,
  logTurnEnd,
  type OpencodeDriver,
  type OpencodeLaunch,
  type RunState,
  recordReasoning,
  recordSubagentDispatch,
  recordText,
  recordToolCall,
  stallCause,
  stallError,
  type TurnText,
  withServerStderr,
} from "./opencodeSession.ts";
import type { AgentResult, AgentUsage } from "./shared.ts";

export const opencode1: OpencodeDriver = {
  gatePlugin: buildOpencodeSubagentGateSource,
  // OPENCODE_PERMISSION has absolute highest precedence (merged after managed/MDM configs).
  env: (params) => ({
    OPENCODE_CONFIG_CONTENT: JSON.stringify(params.config),
    OPENCODE_PERMISSION: JSON.stringify(params.sandbox),
  }),
  open,
};

/**
 * What we collect during a single session.prompt() turn so we can render a
 * unified AgentResult at the end. Per-turn snapshot is reset between turns
 * inside the event loop via `beginTurn()` / `endTurn()`.
 */
interface TurnAccumulator extends TurnText {
  /**
   * Aggregate token totals from step-finish parts across the orchestrator AND
   * any subagent sessions dispatched during the turn (e.g. reviewfrog).
   * Mirrors v1's `accumulatedTokens` semantics so production billing/audit
   * numbers stay apples-to-apples across the migration.
   */
  tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
  costUsd: number;
}

function newTurn(): TurnAccumulator {
  return {
    finalText: "",
    tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
    costUsd: 0,
  };
}

interface RunnerContext extends RunState {
  client: OpencodeClient;
  sessionID: string;
  /**
   * reasoning effort, already clamped to the model's ladder. lives on the
   * context rather than the turn params because `variant` is a per-prompt
   * field, not per-session — a resume that omits it silently drops back to the
   * model default. undefined when the model has no effort control.
   */
  variant: string | undefined;
  model: { providerID: string; modelID: string } | undefined;
  currentTurn: TurnAccumulator | null;
  /** message our own submitted prompt echoes back on — see `isModelOutput`. */
  promptMessageID: string | undefined;
  /**
   * the orchestrator's last `session.error` this turn. opencode reports a model-not-found
   * ONLY there — the prompt itself gets an opaque 500 (#1470).
   */
  lastSessionError: string | undefined;
  /** a provider retry this turn cannot outlast, which ends it at once (#1575) */
  terminalRetry: string | undefined;
  /** aborts the turn in flight; replaced for each prompt */
  turnAbort: AbortController;
}

async function open(launch: OpencodeLaunch) {
  const client = createOpencodeClient({
    baseUrl: launch.server.baseUrl,
    directory: launch.repoDir,
    fetch: launch.fetch,
  });

  const createStartedAt = Date.now();
  const sessionResp = await client.session.create({ title: "Pullfrog" });
  if (sessionResp.error || !sessionResp.data) {
    const msg = sessionResp.error
      ? formatPromptError(sessionResp.error)
      : "session.create returned no data";
    // session.create is the first request to hit the server, so a failed
    // instance bootstrap (an invalid repo config above all) surfaces here.
    throw new Error(`opencode session.create failed: ${msg}`);
  }
  const sessionID = sessionResp.data.id;
  // the first request awaits plugin-dependency installs, so boot latency lands here (#1513)
  log.info(`» opencode session: ${sessionID} (${Date.now() - createStartedAt}ms)`);

  const model = parseModel(launch.model);
  const variant =
    launch.effort.declarable && model
      ? await resolveDeclaredRung(launch, { client, model })
      : launch.effort.rung;

  const ctx: RunnerContext = {
    ...createRunState(launch, sessionID),
    client,
    sessionID,
    variant,
    model,
    currentTurn: null,
    promptMessageID: undefined,
    lastSessionError: undefined,
    terminalRetry: undefined,
    turnAbort: new AbortController(),
  };

  const eventsController = new AbortController();
  const eventLoopPromise = consumeEvents(ctx, eventsController.signal).catch((err) => {
    // SSE stream breakage during cleanup is expected; only surface during
    // active operation.
    if (!eventsController.signal.aborted) {
      log.warning(
        `» opencode event subscription ended: ${err instanceof Error ? err.message : String(err)}`
      );
    }
  });

  return {
    state: ctx,
    prompt: (text: string, signal: AbortSignal) => runPromptTurn(ctx, { text, signal }),
    interrupt: async (sessionID: string) => {
      const resp = await ctx.client.session.abort({ sessionID });
      if (!resp.data) throw new Error(resp.error ? JSON.stringify(resp.error) : "nothing aborted");
    },
    // V1 has no way to reach a running turn; its gate still refuses a late dispatch.
    note: async () => {},
    close: async () => {
      eventsController.abort();
      await eventLoopPromise;
    },
  };
}

/** split `<providerID>/<modelID>` into the SDK's prompt model shape. */
function parseModel(
  value: string | undefined
): { providerID: string; modelID: string } | undefined {
  if (!value) return undefined;
  const slash = value.indexOf("/");
  if (slash <= 0) return undefined;
  return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) };
}

/** the variants the server's own `variants` map declares for the model — the one its request path indexes. */
async function resolveDeclaredRung(
  launch: OpencodeLaunch,
  params: { client: OpencodeClient; model: { providerID: string; modelID: string } }
): Promise<string | undefined> {
  const resp = await params.client.config.providers();
  const provider = resp.data?.providers.find((p) => p.id === params.model.providerID);
  const variants = provider?.models[params.model.modelID]?.variants ?? {};
  return declaredRung(launch, {
    name: `${params.model.providerID}/${params.model.modelID}`,
    variants: Object.keys(variants),
  });
}

// ── events ─────────────────────────────────────────────────────────────────────

/**
 * orchestrate the event stream consumer for the entire server lifetime.
 *
 * NB: the SDK subscribe is lazy — the SSE fetch only opens on the first
 * iteration. so the first turn's tool part-updated events can race the
 * connect and be missed. live-stream logging is best-effort; see the
 * end-of-turn `logUnseenToolCalls` fallback for the guarantee.
 */
async function consumeEvents(ctx: RunnerContext, signal: AbortSignal): Promise<void> {
  // wire the abort signal into the SSE request itself. without it the
  // generated client falls back to an internal never-aborting signal
  // (serverSentEvents.gen.js: `options.signal ?? new AbortController().signal`),
  // so `reader.read()` parks forever once the session goes idle and the stream
  // falls silent. the teardown `await eventLoopPromise` in the run() finally
  // then blocks — `abortController.abort()` can't interrupt a `for await` that
  // never advances — until the outer process-output watchdog kills the
  // already-succeeded run once the flat idle budget elapses and reports a false
  // "stalled" (PR #876).
  const result = await ctx.client.event.subscribe({}, { signal });
  for await (const event of result.stream as AsyncGenerator<EventSubscribeResponse>) {
    if (signal.aborted) break;
    ctx.eventCount += 1;
    ctx.diagnostic.eventCount = ctx.eventCount;
    // NB: `lastEventAt` (the inner-watchdog clock) is intentionally NOT bumped
    // here — opencode's keepalive/lifecycle/idle events would otherwise mask a
    // provider stall (the model going silent mid-turn looks like steady event
    // flow). it is refreshed only on meaningful progress in `dispatchEvent`.
    markActivity();
    try {
      await dispatchEvent(ctx, event);
    } catch (err) {
      log.debug(
        `» event dispatch threw for type=${(event as { type?: string }).type ?? "?"}: ${err instanceof Error ? err.message : String(err)}`
      );
    }
  }
}

/**
 * Whether a part is the model's own output rather than our submission echoed
 * back. `session.prompt` publishes the prompt we just sent as a `text` part
 * immediately, before the provider has been contacted — so "any part arrived"
 * is not the signal the first-event budget wants.
 *
 * The echo is the first `text` part of a turn; everything on a later message is
 * the model's. Non-text parts (`step-start` leads every assistant step) are
 * always the model's, which also covers the lazy-SSE-subscribe race past our
 * own echo.
 *
 * Supplying our own `messageID` to `session.prompt` would make this an exact
 * identity test, and the SDK accepts one — but measured, it silently breaks the
 * post-run reflection turn (11ms and no assistant output, vs 420ms and normal
 * output without it), so the heuristic stays.
 */
function isModelOutput(ctx: RunnerContext, part: Part): boolean {
  if (part.type !== "text") return true;
  if (ctx.promptMessageID === undefined) {
    ctx.promptMessageID = part.messageID;
    return false;
  }
  return part.messageID !== ctx.promptMessageID;
}

async function dispatchEvent(ctx: RunnerContext, event: EventSubscribeResponse): Promise<void> {
  // event union covers heartbeats, session lifecycle, message lifecycle, tui,
  // mcp, etc. we only care about a small subset.
  if (event.type === "message.part.updated") {
    // real model/tool progress: token/text/reasoning streaming and tool
    // part transitions all arrive as part.updated. this is the only event
    // class that refreshes the inner-watchdog clock.
    ctx.lastEventAt = performance.now();
    if (isModelOutput(ctx, event.properties.part)) ctx.diagnostic.sawModelOutput = true;
    onPartUpdated(ctx, event.properties.part);
    return;
  }
  if (event.type === "session.error") {
    const sessionID = event.properties.sessionID;
    if (sessionID !== ctx.orchestratorSessionID) return;
    const err = event.properties.error;
    const message = err ? extractErrorMessage(err) : "(no error payload)";
    log.info(`» ${ctx.label} session error: ${message}`);
    if (err) ctx.lastSessionError = message;
    return;
  }
  if (event.type === "session.status" && event.properties.status.type === "retry") {
    onProviderRetry(ctx, { sessionID: event.properties.sessionID, retry: event.properties.status });
    return;
  }
  // session.idle and the other session.status types are useful breadcrumbs but we don't drive
  // anything off them — the prompt() POST returns when the assistant message
  // is committed, which is also when the session goes idle.
}

/**
 * opencode retries a capped provider for as long as its `retry-after` says, days for a Go
 * cap, and publishes each retry only here: not on stderr and not as an error. ignored, the
 * turn waited out two watchdog budgets and told the customer to re-trigger (#1575). a retry
 * this turn cannot outlast ends it now: an account cap (opencode attaches an `action` only to
 * those) or a wait past the budget the watchdog would allow anyway.
 */
function onProviderRetry(
  ctx: RunnerContext,
  params: { sessionID: string; retry: Extract<SessionStatus, { type: "retry" }> }
): void {
  const waitMs = params.retry.next - Date.now();
  log.info(
    `» ${ctx.label} provider retry #${params.retry.attempt}: ${params.retry.message} (next in ${Math.round(waitMs / 1000)}s)`
  );
  if (params.sessionID !== ctx.orchestratorSessionID) return;
  const match = findProviderErrorMatch(params.retry.message);
  if (match) ctx.diagnostic.lastProviderError = match.label;
  // a shorter wait stays the watchdog's: a salvage re-prompt joins the run the server is still retrying
  if (!params.retry.action && waitMs <= AGENT_ACTIVITY_TIMEOUT_MS) return;
  ctx.terminalRetry = params.retry.message;
  ctx.turnAbort.abort();
}

function extractErrorMessage(err: {
  name?: string;
  data?: { message?: string; [key: string]: unknown };
}): string {
  if (err.data?.message) return err.data.message;
  if (err.name) return err.name;
  return JSON.stringify(err);
}

function onPartUpdated(ctx: RunnerContext, part: Part): void {
  // text — only orchestrator's final text becomes the run's "output";
  // subagent text is logged but not folded into finalOutput.
  if (part.type === "text" && part.time?.end !== undefined) {
    recordText(ctx, { sessionID: part.sessionID, text: part.text });
    return;
  }

  if (part.type === "reasoning" && part.time.end !== undefined) {
    recordReasoning(ctx, {
      sessionID: part.sessionID,
      text: part.text,
      duration: formatPartDuration(part.time),
    });
    return;
  }

  if (part.type === "step-finish") {
    // aggregate orchestrator AND subagent step-finish events into the same
    // per-turn accumulator. v1 (`opencode.ts`) summed both via opencode's
    // CLI `--print-logs` output; filtering subagents here would silently
    // undercount production cost/usage by the reviewfrog subagent's
    // contribution (often the bulk of a Review-mode turn).
    if (!ctx.currentTurn) return;
    const t = part.tokens;
    if (t) {
      ctx.currentTurn.tokens.input += t.input || 0;
      ctx.currentTurn.tokens.output += t.output || 0;
      ctx.currentTurn.tokens.cacheRead += t.cache?.read || 0;
      ctx.currentTurn.tokens.cacheWrite += t.cache?.write || 0;
    }
    if (typeof part.cost === "number" && Number.isFinite(part.cost)) {
      ctx.currentTurn.costUsd += part.cost;
    }
    return;
  }

  if (part.type === "tool") {
    onToolPart(ctx, part);
    return;
  }

  // step-start / snapshot / patch / agent / retry / compaction / subtask /
  // file: nothing actionable here.
}

function onToolPart(ctx: RunnerContext, part: Extract<Part, { type: "tool" }>): void {
  // early task-dispatch announce: bind subagent sessionID to a label as soon
  // as the orchestrator's task tool transitions to "running" (where input is
  // populated). dedupe against later terminal observations via callID.
  if (
    part.tool === "task" &&
    part.state.status === "running" &&
    part.sessionID === ctx.orchestratorSessionID
  ) {
    const input = (part.state.input ?? {}) as {
      description?: string;
      subagent_type?: string;
      prompt?: string;
    };
    recordSubagentDispatch(ctx, { callID: part.callID, input });
    // the child's id lands on a later running update, once the task tool publishes its metadata.
    const child = part.state.metadata?.sessionId;
    if (typeof child === "string")
      bindSubagentSession(ctx, { callID: part.callID, sessionID: child });
    return;
  }
  recordTerminalToolPart(ctx, part);
}

/** a tool part that reached a terminal state, through the shared bookkeeping. */
function recordTerminalToolPart(ctx: RunnerContext, part: Extract<Part, { type: "tool" }>): void {
  const state = part.state;
  if (state.status !== "completed" && state.status !== "error") return;
  recordToolCall(ctx, {
    tool: part.tool,
    callID: part.callID,
    sessionID: part.sessionID,
    input: state.input,
    output: state.status === "completed" ? state.output : undefined,
    error: state.status === "error" ? state.error : undefined,
  });
}

/**
 * end-of-turn safety net for tool-call bookkeeping. queries `session.messages`
 * for the canonical orchestrator transcript and replays any tool callID the
 * live event stream hasn't already processed — closes the SSE-connect race
 * documented on `loggedToolCallIDs`. `session.prompt`'s own `data.parts` is
 * only the final assistant message's parts (mostly text/reasoning); the tool
 * calls in earlier steps of the same turn live on prior messages, so we need
 * the full session-scoped read.
 */
async function logUnseenToolCalls(ctx: RunnerContext): Promise<void> {
  try {
    const resp = await ctx.client.session.messages({ sessionID: ctx.orchestratorSessionID });
    if (resp.error || !resp.data) return;
    for (const message of resp.data) {
      for (const part of message.parts) {
        if (part.type !== "tool") continue;
        recordTerminalToolPart(ctx, part);
      }
    }
  } catch (err) {
    log.debug(`» logUnseenToolCalls failed: ${err instanceof Error ? err.message : String(err)}`);
  }
}

function formatPartDuration(time: { start?: number; end?: number } | undefined): string {
  if (!time || typeof time.start !== "number" || typeof time.end !== "number") return "";
  if (time.end <= time.start) return "";
  return ` (${((time.end - time.start) / 1000).toFixed(1)}s)`;
}

// ── per-turn execution ─────────────────────────────────────────────────────────

/**
 * Run a single prompt turn against the persistent server. Resets the per-turn
 * accumulator, calls `client.session.prompt()`, then assembles an AgentResult
 * from the returned AssistantMessage + accumulated event state.
 *
 * Token / cost: `AssistantMessage.tokens` and `.cost` are authoritative for
 * the turn. The event-stream accumulator is a fallback / sanity-check path
 * used when the response is missing (e.g. abort, transport error) — and as
 * the only source of per-step subagent attribution if we ever surface it.
 */
async function runPromptTurn(
  ctx: RunnerContext,
  params: { text: string; signal: AbortSignal }
): Promise<AgentResult> {
  const start = performance.now();
  // record the turn boundary in milliseconds (matches AssistantMessage.time.created)
  // so the post-turn aggregator can isolate this turn's messages from the prior
  // turns' messages on the same persistent orchestrator session.
  const turnStartMs = Date.now();
  ctx.currentTurn = newTurn();
  const turn = ctx.currentTurn;

  const part: TextPartInput = { type: "text", text: params.text };

  // re-arm the pre-first-token budget for every turn: a resumed turn waits on
  // the provider exactly the way the first one does.
  ctx.promptMessageID = undefined;
  // both fields describe the turn in flight, never a past one: the outer
  // process-output timer can terminalize the run long after a fire the harness
  // recovered from, and would otherwise render that stale fire's story.
  ctx.diagnostic.sawModelOutput = false;
  ctx.diagnostic.idleSec = undefined;
  ctx.lastSessionError = undefined;
  ctx.terminalRetry = undefined;
  ctx.turnAbort = new AbortController();
  const signal = AbortSignal.any([params.signal, ctx.turnAbort.signal]);

  let assistant: AssistantMessage | undefined;
  let returnedParts: Part[] | undefined;
  let networkError: string | null = null;
  try {
    const response = await ctx.client.session.prompt(
      {
        sessionID: ctx.sessionID,
        parts: [part],
        ...(ctx.variant ? { variant: ctx.variant } : {}),
        ...(ctx.model ? { model: ctx.model } : {}),
      },
      // wire the inner activity watchdog's abort signal into the SDK request
      // — without this a hung HTTP keeps the run stuck even after the
      // watchdog fires.
      { signal }
    );
    if (response.error) {
      networkError = formatPromptError(response.error);
    } else if (response.data) {
      assistant = response.data.info;
      returnedParts = response.data.parts;
    } else {
      // neither error nor data — malformed/partial SDK response. don't silently
      // succeed with an empty AgentResult; treat as a failure so the gate loop
      // surfaces it instead of looping on a "successful" no-op.
      networkError = "opencode prompt returned neither data nor error";
    }
  } catch (err) {
    networkError = err instanceof Error ? err.message : String(err);
  }
  const durationMs = performance.now() - start;

  // authoritative cost/usage: walk every assistant message that landed during
  // this turn (orchestrator session + any subagent sessions dispatched while
  // it ran) and sum tokens + cost. The step-finish accumulator and the live
  // AssistantMessage from session.prompt are both non-authoritative for a
  // multi-step turn — step-finish events arrive on the SSE stream after
  // session.prompt has already resolved (at least for the final message),
  // and AssistantMessage carries only the final message's usage. Mirrors v1's
  // accumulator-after-the-fact model but driven by the canonical message
  // store instead of best-effort SSE sniffing.
  const aggregatedUsage = await aggregateTurnUsage(ctx, turnStartMs);
  const usage = aggregatedUsage ?? buildUsage(turn, assistant);

  // surface the rendered final text. preference order:
  //   1. orchestrator text part with time.end set (captured by event loop)
  //   2. text part on the returned response (when present)
  //   3. assistant message id as a last-resort placeholder
  const finalText = turn.finalText || extractTextFromParts(returnedParts) || "";

  await logUnseenToolCalls(ctx);
  logTurnEnd(ctx, { durationMs, usage });

  // failure modes, in order of authority:
  //   1. transport / SDK-side error (response.error or thrown)
  //   2. AssistantMessage.error set by the provider (auth, context overflow, etc.)
  // a bare `session.error` is deliberately NOT a third mode: opencode publishes
  // it for conditions it recovers from, and sets (2) on the terminal ones (#1069).
  // a run that dies holding a classified provider error must never be
  // reported as "the model went silent" — the stderr subscriber knew the
  // cause seconds after session.create, and the claude harness already names
  // it on its own timeout path. see #1183.
  // a dead server outranks a provider error: it is why every later request failed.
  const cause = stallCause(ctx);
  // a session error explains only a prompt that failed before any output (model-not-found): one
  // after output, or after a watchdog abort, is a condition opencode recovered from (#1069).
  const promptCause =
    ctx.serverExitCause() ??
    (ctx.diagnostic.sawModelOutput ? undefined : ctx.lastSessionError) ??
    ctx.diagnostic.lastProviderError;

  if (networkError && ctx.terminalRetry) {
    // a provider error, not a hang: no salvage, and the usage-limit copy rather than "re-trigger"
    return {
      success: false,
      output: finalText,
      error: `provider error: ${ctx.terminalRetry}`,
      usage,
    };
  }
  if (networkError) {
    // a watchdog-fired abort surfaces here as a caught `session.prompt`
    // rejection (or an aborted `response.error`), not a throw that escapes to
    // the caller. classify it as an `activity timeout` so `renderRunError`
    // routes it through the hang renderer; any other transport failure falls
    // through to the generic humanized renderer.
    return {
      success: false,
      output: finalText,
      error: params.signal.aborted
        ? stallError({
            what: `the model went silent and the turn was aborted by the activity watchdog (${networkError})`,
            cause,
          })
        : promptCause
          ? `opencode prompt failed: ${networkError} — likely cause: ${promptCause}`
          : // an untyped server defect leaves its cause only in the server's log (#1586)
            withServerStderr(`opencode prompt failed: ${networkError}`, ctx.serverStderr),
      usage,
    };
  }
  if (assistant?.error) {
    return {
      success: false,
      output: finalText,
      error: `provider error: ${extractErrorMessage(assistant.error)}`,
      usage,
    };
  }

  const result = { success: true, output: finalText, usage };
  return emptyTurn(ctx, result) ?? result;
}

/**
 * Sum the cost + tokens of every assistant message created during this turn,
 * across the orchestrator session AND any subagent sessions dispatched while
 * it ran. Authoritative: the SDK's own cost/tokens fields per message are the
 * source of truth, identical to what `opencode --print-logs` aggregated in v1.
 */
async function aggregateTurnUsage(
  ctx: RunnerContext,
  turnStartMs: number
): Promise<AgentUsage | undefined> {
  // labeler tracks every sessionID we've observed events from on the
  // global SSE stream, including any subagent (task tool) child sessions.
  const sessionIDs = new Set<string>([ctx.orchestratorSessionID]);
  for (const [sessionID] of ctx.labeler.entries()) {
    sessionIDs.add(sessionID);
  }

  let inputTokens = 0;
  let outputTokens = 0;
  let cacheReadTokens = 0;
  let cacheWriteTokens = 0;
  let costUsd = 0;
  let counted = 0;

  for (const sessionID of sessionIDs) {
    try {
      const resp = await ctx.client.session.messages({ sessionID });
      if (resp.error || !resp.data) continue;
      for (const msg of resp.data) {
        if (msg.info.role !== "assistant") continue;
        if (msg.info.time.created < turnStartMs) continue;
        const t = msg.info.tokens;
        inputTokens += t.input || 0;
        outputTokens += t.output || 0;
        cacheReadTokens += t.cache?.read || 0;
        cacheWriteTokens += t.cache?.write || 0;
        costUsd += msg.info.cost || 0;
        counted++;
      }
    } catch (err) {
      log.debug(
        `» aggregateTurnUsage failed for session ${sessionID}: ${err instanceof Error ? err.message : String(err)}`
      );
    }
  }

  if (counted === 0) return undefined;

  const total = inputTokens + cacheReadTokens + cacheWriteTokens;
  if (total === 0 && outputTokens === 0 && costUsd === 0) return undefined;

  return {
    agent: "pullfrog",
    inputTokens: total,
    outputTokens,
    cacheReadTokens: cacheReadTokens || undefined,
    cacheWriteTokens: cacheWriteTokens || undefined,
    costUsd: costUsd > 0 ? costUsd : undefined,
  };
}

function buildUsage(
  turn: TurnAccumulator,
  assistant: AssistantMessage | undefined
): AgentUsage | undefined {
  // Prefer the step-finish accumulator: it sums every LLM call across the
  // whole turn (orchestrator iterations + any subagent dispatches). The
  // AssistantMessage at the SDK boundary only carries the *final* assistant
  // message's tokens/cost — for a multi-step Review-mode turn that's just
  // the closing acknowledgment, missing the bulk of the work. Fall back to
  // assistant.tokens only if the accumulator is empty (e.g., the turn
  // errored before any step-finish events landed).
  const t = turn.tokens;
  const accumulatorTotal = t.input + t.cacheRead + t.cacheWrite;
  if (accumulatorTotal > 0 || t.output > 0 || turn.costUsd > 0) {
    return {
      agent: "pullfrog",
      inputTokens: accumulatorTotal,
      outputTokens: t.output,
      cacheReadTokens: t.cacheRead || undefined,
      cacheWriteTokens: t.cacheWrite || undefined,
      costUsd: turn.costUsd > 0 ? turn.costUsd : undefined,
    };
  }
  if (assistant) {
    const at = assistant.tokens;
    const total = (at.input || 0) + (at.cache?.read || 0) + (at.cache?.write || 0);
    if (total === 0 && (at.output || 0) === 0 && (assistant.cost || 0) === 0) return undefined;
    return {
      agent: "pullfrog",
      inputTokens: total,
      outputTokens: at.output || 0,
      cacheReadTokens: at.cache?.read || undefined,
      cacheWriteTokens: at.cache?.write || undefined,
      costUsd: assistant.cost > 0 ? assistant.cost : undefined,
    };
  }
  return undefined;
}

function extractTextFromParts(parts: Part[] | undefined): string | undefined {
  if (!parts) return undefined;
  const texts: string[] = [];
  for (const p of parts) {
    if (p.type === "text" && p.text) texts.push(p.text);
  }
  const joined = texts.join("\n").trim();
  return joined || undefined;
}

function formatPromptError(error: unknown): string {
  if (typeof error === "string") return error;
  if (error && typeof error === "object") {
    const obj = error as {
      name?: string;
      message?: string;
      error?: { message?: string };
      data?: unknown;
    };
    if (obj.message) return obj.message;
    if (obj.error?.message) return obj.error.message;
    const config = formatConfigError(obj.name, obj.data);
    if (config) return config;
    try {
      return JSON.stringify(error);
    } catch {
      return String(error);
    }
  }
  return String(error);
}

/**
 * Render opencode's typed config errors (`ConfigInvalidError`, `ConfigJsonError`,
 * `ConfigFrontmatterError`, `ConfigDirectoryTypoError`).
 *
 * They name the offending file and the exact issues but carry no top-level
 * `message`, so the generic branch above would JSON.stringify them. A repo config
 * error is fatal at instance bootstrap — it fails the FIRST request, whatever it is
 * — so this string is all a user with a bad `opencode.json` gets to act on.
 */
function formatConfigError(name: string | undefined, data: unknown): string | undefined {
  if (!name?.startsWith("Config") || !data || typeof data !== "object") return undefined;
  const at = "path" in data && typeof data.path === "string" ? ` at ${data.path}` : "";
  // ConfigFrontmatterError / ConfigJsonError carry their detail here, not in `issues`.
  const detail =
    "message" in data && typeof data.message === "string" ? `${at}: ${data.message}` : at;
  const issues = "issues" in data && Array.isArray(data.issues) ? data.issues : [];
  const bullets = issues
    .map((issue) =>
      issue && typeof issue === "object" && "message" in issue && typeof issue.message === "string"
        ? `\n↳ ${issue.message}`
        : ""
    )
    .join("");
  return `${name}${detail}${bullets}`;
}