From ff5005dec648e6d63a54b47ddf32ccb8217987fb Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sat, 18 Apr 2026 23:08:19 +0200 Subject: [PATCH] add otel --- packages/opencode/src/cli/cmd/run/footer.ts | 40 +- packages/opencode/src/cli/cmd/run/otel.ts | 119 +++ .../src/cli/cmd/run/runtime.lifecycle.ts | 248 +++--- packages/opencode/src/cli/cmd/run/runtime.ts | 812 ++++++++++-------- .../src/cli/cmd/run/scrollback.surface.ts | 16 +- packages/opencode/test/cli/run/footer.test.ts | 1 + 6 files changed, 748 insertions(+), 488 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/otel.ts diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 3bb3b92cf2..684541c97e 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -27,6 +27,7 @@ import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opent import { render } from "@opentui/solid" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" import { createStore, reconcile } from "solid-js/store" +import { withRunSpan } from "./otel" import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent" import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt" import { printableBinding } from "./prompt.shared" @@ -62,6 +63,7 @@ type RunFooterOptions = { findFiles: (query: string) => Promise agents: RunAgent[] resources: RunResource[] + sessionID: () => string | undefined agentLabel: string modelLabel: string first: boolean @@ -203,6 +205,7 @@ export class RunFooter implements FooterApi { this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc" this.scrollback = new RunScrollbackStream(renderer, options.theme, { diffStyle: options.diffStyle, + sessionID: options.sessionID, treeSitterClient: options.treeSitterClient, }) @@ -338,7 +341,21 @@ export class RunFooter implements FooterApi { } private completeScrollback(): void { - this.flushing = this.flushing.then(() => this.scrollback.complete()).catch(() => {}) + const phase = this.state().phase + this.flushing = this.flushing + .then(() => + withRunSpan( + "RunFooter.completeScrollback", + { + "opencode.footer.phase": phase, + "session.id": this.options.sessionID() || undefined, + }, + async () => { + await this.scrollback.complete() + }, + ), + ) + .catch(() => {}) } private present(view: FooterView): void { @@ -662,12 +679,23 @@ export class RunFooter implements FooterApi { } const batch = this.queue.splice(0) + const phase = this.state().phase this.flushing = this.flushing - .then(async () => { - for (const item of batch) { - await this.scrollback.append(item) - } - }) + .then(() => + withRunSpan( + "RunFooter.flush", + { + "opencode.batch.commits": batch.length, + "opencode.footer.phase": phase, + "session.id": this.options.sessionID() || undefined, + }, + async () => { + for (const item of batch) { + await this.scrollback.append(item) + } + }, + ), + ) .catch(() => {}) } } diff --git a/packages/opencode/src/cli/cmd/run/otel.ts b/packages/opencode/src/cli/cmd/run/otel.ts new file mode 100644 index 0000000000..d4f20c92b9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/otel.ts @@ -0,0 +1,119 @@ +import { INVALID_SPAN_CONTEXT, context, trace, SpanStatusCode, type Span } from "@opentelemetry/api" +import { Effect, ManagedRuntime } from "effect" +import { memoMap } from "@/effect/memo-map" +import { Observability } from "@/effect/observability" + +type AttributeValue = string | number | boolean | undefined + +export type RunSpanAttributes = Record + +const noop = trace.wrapSpanContext(INVALID_SPAN_CONTEXT) +const tracer = trace.getTracer("opencode.run") +const runtime = ManagedRuntime.make(Observability.layer, { memoMap }) +let ready: Promise | undefined + +function attributes(input?: RunSpanAttributes) { + if (!input) { + return + } + + const out = Object.entries(input).flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const])) + if (out.length === 0) { + return + } + + return Object.fromEntries(out) +} + +function message(error: unknown) { + if (typeof error === "string") { + return error + } + + if (error instanceof Error) { + return error.message || error.name + } + + return String(error) +} + +function ensure() { + if (!Observability.enabled) { + return Promise.resolve() + } + + if (ready) { + return ready + } + + ready = runtime.runPromise(Effect.void).then( + () => undefined, + (error) => { + ready = undefined + throw error + }, + ) + return ready +} + +function finish(span: Span, out: Promise) { + return out.then( + (value) => { + span.end() + return value + }, + (error) => { + recordRunSpanError(span, error) + span.end() + throw error + }, + ) +} + +export function setRunSpanAttributes(span: Span, input?: RunSpanAttributes): void { + const next = attributes(input) + if (!next) { + return + } + + span.setAttributes(next) +} + +export function recordRunSpanError(span: Span, error: unknown): void { + const next = message(error) + span.recordException(error instanceof Error ? error : next) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: next, + }) +} + +export function withRunSpan( + name: string, + input: RunSpanAttributes | undefined, + fn: (span: Span) => Promise | A, +): A | Promise { + if (!Observability.enabled) { + return fn(noop) + } + + return ensure().then( + () => { + const span = tracer.startSpan(name, { + attributes: attributes(input), + }) + + return context.with( + trace.setSpan(context.active(), span), + () => + finish( + span, + new Promise((resolve) => { + resolve(fn(span)) + }), + ), + ) + }, + () => fn(noop), + ) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index 33ee30316a..068cbe70ec 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -10,6 +10,7 @@ // sequence through RunFooter.requestExit(). import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import * as Locale from "../../../util/locale" +import { withRunSpan } from "./otel" import { entrySplash, exitSplash, splashMeta } from "./splash" import { resolveRunTheme } from "./theme" import type { @@ -51,6 +52,7 @@ export type LifecycleInput = { resources: RunResource[] sessionID: string sessionTitle?: string + getSessionID?: () => string | undefined first: boolean history: RunPrompt[] agent: string | undefined @@ -149,117 +151,141 @@ function queueSplash( // scrollback commits and footer repaints happen in the same frame. After // the entry splash, RunFooter takes over the footer region. export async function createRuntimeLifecycle(input: LifecycleInput): Promise { - const renderer = await createCliRenderer({ - targetFps: 30, - maxFps: 60, - useMouse: false, - autoFocus: false, - openConsoleOnError: false, - exitOnCtrlC: false, - useKittyKeyboard: { events: process.platform === "win32" }, - screenMode: "split-footer", - footerHeight: FOOTER_HEIGHT, - externalOutputMode: "capture-stdout", - consoleMode: "disabled", - clearOnShutdown: false, - }) - let theme = await resolveRunTheme(renderer) - renderer.setBackgroundColor(theme.background) - const state: SplashState = { - entry: false, - exit: false, - } - const splash = splashInfo(input.sessionTitle, input.history) - const meta = splashMeta({ - title: splash.title, - session_id: input.sessionID, - }) - const footerTask = import("./footer") - queueSplash( - renderer, - state, - "entry", - entrySplash({ - ...meta, - theme: theme.entry, - background: theme.background, - showSession: splash.showSession, - }), - ) - await renderer.idle().catch(() => {}) - - const { RunFooter } = await footerTask - - const labels = footerLabels({ - agent: input.agent, - model: input.model, - variant: input.variant, - }) - const footer = new RunFooter(renderer, { - directory: input.directory, - findFiles: input.findFiles, - agents: input.agents, - resources: input.resources, - ...labels, - first: input.first, - history: input.history, - theme, - keybinds: input.keybinds, - diffStyle: input.diffStyle, - onPermissionReply: input.onPermissionReply, - onQuestionReply: input.onQuestionReply, - onQuestionReject: input.onQuestionReject, - onCycleVariant: input.onCycleVariant, - onInterrupt: input.onInterrupt, - onSubagentSelect: input.onSubagentSelect, - }) - - const sigint = () => { - footer.requestExit() - } - process.on("SIGINT", sigint) - - let closed = false - const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string }) => { - if (closed) { - return - } - - closed = true - process.off("SIGINT", sigint) - - try { - await footer.idle().catch(() => {}) - - const show = renderer.isDestroyed ? false : next.showExit - if (!renderer.isDestroyed && show) { - const sessionID = next.sessionID ?? input.sessionID - const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, input.history) - queueSplash( - renderer, - state, - "exit", - exitSplash({ - ...splashMeta({ - title: splash.title, - session_id: sessionID, - }), - theme: theme.entry, - background: theme.background, - }), - ) - await renderer.idle().catch(() => {}) + return withRunSpan( + "RunLifecycle.boot", + { + "opencode.agent.name": input.agent, + "opencode.directory": input.directory, + "opencode.first": input.first, + "opencode.model.provider": input.model?.providerID, + "opencode.model.id": input.model?.modelID, + "opencode.model.variant": input.variant, + "session.id": input.getSessionID?.() || input.sessionID || undefined, + }, + async () => { + const renderer = await createCliRenderer({ + targetFps: 30, + maxFps: 60, + useMouse: false, + autoFocus: false, + openConsoleOnError: false, + exitOnCtrlC: false, + useKittyKeyboard: { events: process.platform === "win32" }, + screenMode: "split-footer", + footerHeight: FOOTER_HEIGHT, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + clearOnShutdown: false, + }) + const theme = await resolveRunTheme(renderer) + renderer.setBackgroundColor(theme.background) + const state: SplashState = { + entry: false, + exit: false, } - } finally { - footer.close() - await footer.idle().catch(() => {}) - footer.destroy() - shutdown(renderer) - } - } + const splash = splashInfo(input.sessionTitle, input.history) + const meta = splashMeta({ + title: splash.title, + session_id: input.sessionID, + }) + const footerTask = import("./footer") + queueSplash( + renderer, + state, + "entry", + entrySplash({ + ...meta, + theme: theme.entry, + background: theme.background, + showSession: splash.showSession, + }), + ) + await renderer.idle().catch(() => {}) - return { - footer, - close, - } + const { RunFooter } = await footerTask + + const labels = footerLabels({ + agent: input.agent, + model: input.model, + variant: input.variant, + }) + const footer = new RunFooter(renderer, { + directory: input.directory, + findFiles: input.findFiles, + agents: input.agents, + resources: input.resources, + sessionID: input.getSessionID ?? (() => input.sessionID), + ...labels, + first: input.first, + history: input.history, + theme, + keybinds: input.keybinds, + diffStyle: input.diffStyle, + onPermissionReply: input.onPermissionReply, + onQuestionReply: input.onQuestionReply, + onQuestionReject: input.onQuestionReject, + onCycleVariant: input.onCycleVariant, + onInterrupt: input.onInterrupt, + onSubagentSelect: input.onSubagentSelect, + }) + + const sigint = () => { + footer.requestExit() + } + process.on("SIGINT", sigint) + + let closed = false + const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string }) => { + if (closed) { + return + } + + closed = true + return withRunSpan( + "RunLifecycle.close", + { + "opencode.show_exit": next.showExit, + "session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined, + }, + async () => { + process.off("SIGINT", sigint) + + try { + await footer.idle().catch(() => {}) + + const show = renderer.isDestroyed ? false : next.showExit + if (!renderer.isDestroyed && show) { + const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID + const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, input.history) + queueSplash( + renderer, + state, + "exit", + exitSplash({ + ...splashMeta({ + title: splash.title, + session_id: sessionID, + }), + theme: theme.entry, + background: theme.background, + }), + ) + await renderer.idle().catch(() => {}) + } + } finally { + footer.close() + await footer.idle().catch(() => {}) + footer.destroy() + shutdown(renderer) + } + }, + ) + } + + return { + footer, + close, + } + }, + ) } diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 0fff7adf57..0f34b05ac5 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -17,6 +17,7 @@ import { Flag } from "@/flag/flag" import { createRunDemo } from "./demo" import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" +import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel" import { reusePendingTask } from "./runtime.shared" import { trace } from "./trace" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" @@ -74,418 +75,489 @@ type StreamState = { // Files only attach on the first prompt turn -- after that, includeFiles // flips to false so subsequent turns don't re-send attachments. async function runInteractiveRuntime(input: RunRuntimeInput): Promise { - const start = performance.now() - const log = trace() - const keybindTask = resolveFooterKeybinds() - const diffTask = resolveDiffStyle() - const ctx = await input.boot() - const modelTask = resolveModelInfo(ctx.sdk, ctx.model) - const sessionTask = - ctx.resume === true - ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) - : Promise.resolve({ - first: true, - history: [], - variant: undefined, - }) - const savedTask = resolveSavedVariant(ctx.model) - let variants: string[] = [] - let limits: Record = {} - let aborting = false - let shown = false - let demo: ReturnType | undefined - const [keybinds, diffStyle, session, savedVariant] = await Promise.all([ - keybindTask, - diffTask, - sessionTask, - savedTask, - ]) - shown = !session.first - let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants) - let sessionID = ctx.sessionID - let sessionTitle = ctx.sessionTitle - let agent = ctx.agent - let hasSession = !input.resolveSession - let resolving: Promise | undefined - const ensureSession = () => { - if (!input.resolveSession) { - return Promise.resolve() - } - - if (resolving) { - return resolving - } - - resolving = input.resolveSession(ctx).then((next) => { - sessionID = next.sessionID - sessionTitle = next.sessionTitle - agent = next.agent - hasSession = true - }) - return resolving - } - let selectSubagent: ((sessionID: string | undefined) => void) | undefined - - const shell = await createRuntimeLifecycle({ - directory: ctx.directory, - findFiles: (query) => - ctx.sdk.find - .files({ query, directory: ctx.directory }) - .then((x) => x.data ?? []) - .catch(() => []), - agents: [], - resources: [], - sessionID, - sessionTitle, - first: session.first, - history: session.history, - agent, - model: ctx.model, - variant: activeVariant, - keybinds, - diffStyle, - onPermissionReply: async (next) => { - if (demo?.permission(next)) { - return - } - - log?.write("send.permission.reply", next) - await ctx.sdk.permission.reply(next) + return withRunSpan( + "RunInteractive.session", + { + "opencode.mode": input.resolveSession ? "local" : "attach", + "opencode.initial_input": !!input.initialInput, + "opencode.demo": input.demo, }, - onQuestionReply: async (next) => { - if (demo?.questionReply(next)) { - return - } - - await ctx.sdk.question.reply(next) - }, - onQuestionReject: async (next) => { - if (demo?.questionReject(next)) { - return - } - - await ctx.sdk.question.reject(next) - }, - onCycleVariant: () => { - if (!ctx.model || variants.length === 0) { - return { - status: "no variants available", + async (span) => { + const start = performance.now() + const log = trace() + const keybindTask = resolveFooterKeybinds() + const diffTask = resolveDiffStyle() + const ctx = await input.boot() + const modelTask = resolveModelInfo(ctx.sdk, ctx.model) + const sessionTask = + ctx.resume === true + ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) + : Promise.resolve({ + first: true, + history: [], + variant: undefined, + }) + const savedTask = resolveSavedVariant(ctx.model) + let variants: string[] = [] + let limits: Record = {} + let aborting = false + let shown = false + let demo: ReturnType | undefined + const [keybinds, diffStyle, session, savedVariant] = await Promise.all([ + keybindTask, + diffTask, + sessionTask, + savedTask, + ]) + shown = !session.first + let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants) + let sessionID = ctx.sessionID + let sessionTitle = ctx.sessionTitle + let agent = ctx.agent + let hasSession = !input.resolveSession + setRunSpanAttributes(span, { + "opencode.directory": ctx.directory, + "opencode.resume": ctx.resume === true, + "opencode.agent.name": agent, + "opencode.model.provider": ctx.model?.providerID, + "opencode.model.id": ctx.model?.modelID, + "opencode.model.variant": activeVariant, + "session.id": sessionID || undefined, + }) + let resolving: Promise | undefined + const ensureSession = () => { + if (!input.resolveSession) { + return Promise.resolve() } + + if (resolving) { + return resolving + } + + resolving = input.resolveSession(ctx).then((next) => { + sessionID = next.sessionID + sessionTitle = next.sessionTitle + agent = next.agent + hasSession = true + setRunSpanAttributes(span, { + "opencode.agent.name": agent, + "session.id": sessionID, + }) + }) + return resolving + } + let selectSubagent: ((sessionID: string | undefined) => void) | undefined + + const shell = await createRuntimeLifecycle({ + directory: ctx.directory, + findFiles: (query) => + ctx.sdk.find + .files({ query, directory: ctx.directory }) + .then((x) => x.data ?? []) + .catch(() => []), + agents: [], + resources: [], + sessionID, + sessionTitle, + getSessionID: () => sessionID, + first: session.first, + history: session.history, + agent, + model: ctx.model, + variant: activeVariant, + keybinds, + diffStyle, + onPermissionReply: async (next) => { + if (demo?.permission(next)) { + return + } + + log?.write("send.permission.reply", next) + await ctx.sdk.permission.reply(next) + }, + onQuestionReply: async (next) => { + if (demo?.questionReply(next)) { + return + } + + await ctx.sdk.question.reply(next) + }, + onQuestionReject: async (next) => { + if (demo?.questionReject(next)) { + return + } + + await ctx.sdk.question.reject(next) + }, + onCycleVariant: () => { + if (!ctx.model || variants.length === 0) { + return { + status: "no variants available", + } + } + + activeVariant = cycleVariant(activeVariant, variants) + saveVariant(ctx.model, activeVariant) + setRunSpanAttributes(span, { + "opencode.model.variant": activeVariant, + }) + return { + status: activeVariant ? `variant ${activeVariant}` : "variant default", + modelLabel: formatModelLabel(ctx.model, activeVariant), + } + }, + onInterrupt: () => { + if (!hasSession) { + return + } + + if (aborting) { + return + } + + aborting = true + void ctx.sdk.session + .abort({ + sessionID, + }) + .catch(() => {}) + .finally(() => { + aborting = false + }) + }, + onSubagentSelect: (sessionID) => { + selectSubagent?.(sessionID) + log?.write("subagent.select", { + sessionID, + }) + }, + }) + const footer = shell.footer + + let catalogTask: Promise | undefined + const loadCatalog = () => { + if (catalogTask) { + return catalogTask + } + + catalogTask = Promise.all([ + ctx.sdk.app + .agents({ directory: ctx.directory }) + .then((x) => x.data ?? []) + .catch(() => []), + ctx.sdk.experimental.resource + .list({ directory: ctx.directory }) + .then((x) => Object.values(x.data ?? {})) + .catch(() => []), + ]) + .then(([agents, resources]) => { + if (footer.isClosed) { + return + } + + footer.event({ + type: "catalog", + agents, + resources, + }) + }) + .catch(() => {}) + + return catalogTask } - activeVariant = cycleVariant(activeVariant, variants) - saveVariant(ctx.model, activeVariant) - return { - status: activeVariant ? `variant ${activeVariant}` : "variant default", - modelLabel: formatModelLabel(ctx.model, activeVariant), - } - }, - onInterrupt: () => { - if (!hasSession) { - return - } + void footer + .idle() + .then(() => { + if (footer.isClosed) { + return + } - if (aborting) { - return - } - - aborting = true - void ctx.sdk.session - .abort({ - sessionID, + void loadCatalog() }) .catch(() => {}) - .finally(() => { - aborting = false + + if (Flag.OPENCODE_SHOW_TTFD) { + footer.append({ + kind: "system", + text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`, + phase: "final", + source: "system", }) - }, - onSubagentSelect: (sessionID) => { - selectSubagent?.(sessionID) - log?.write("subagent.select", { - sessionID, - }) - }, - }) - const footer = shell.footer + } - let catalogTask: Promise | undefined - const loadCatalog = () => { - if (catalogTask) { - return catalogTask - } + if (input.demo) { + await ensureSession() + demo = createRunDemo({ + mode: input.demo, + text: input.demoText, + footer, + sessionID, + thinking: input.thinking, + limits: () => limits, + }) + } - catalogTask = Promise.all([ - ctx.sdk.app - .agents({ directory: ctx.directory }) - .then((x) => x.data ?? []) - .catch(() => []), - ctx.sdk.experimental.resource - .list({ directory: ctx.directory }) - .then((x) => Object.values(x.data ?? {})) - .catch(() => []), - ]) - .then(([agents, resources]) => { - if (footer.isClosed) { + if (input.afterPaint) { + void Promise.resolve(input.afterPaint(ctx)).catch(() => {}) + } + + void modelTask.then((info) => { + variants = info.variants + limits = info.limits + + const next = resolveVariant(ctx.variant, session.variant, savedVariant, variants) + if (next === activeVariant) { + return + } + + activeVariant = next + setRunSpanAttributes(span, { + "opencode.model.variant": activeVariant, + }) + if (!ctx.model || footer.isClosed) { return } footer.event({ - type: "catalog", - agents, - resources, + type: "model", + model: formatModelLabel(ctx.model, activeVariant), }) }) - .catch(() => {}) - return catalogTask - } + const streamTask = import("./stream.transport") + let stream: StreamState | undefined + const loading: { current?: Promise } = {} + const ensureStream = () => { + if (stream) { + return Promise.resolve(stream) + } - void footer - .idle() - .then(() => { - if (footer.isClosed) { - return + return reusePendingTask(loading, async () => { + await ensureSession() + if (footer.isClosed) { + throw new Error("runtime closed") + } + + const mod = await streamTask + if (footer.isClosed) { + throw new Error("runtime closed") + } + + const handle = await mod.createSessionTransport({ + sdk: ctx.sdk, + sessionID, + thinking: input.thinking, + limits: () => limits, + footer, + trace: log, + }) + if (footer.isClosed) { + await handle.close() + throw new Error("runtime closed") + } + + selectSubagent = handle.selectSubagent + const next = { mod, handle } + stream = next + return next + }) } - void loadCatalog() - }) - .catch(() => {}) + const runQueue = async () => { + let includeFiles = true + if (demo) { + await demo.start() + } - if (Flag.OPENCODE_SHOW_TTFD) { - footer.append({ - kind: "system", - text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`, - phase: "final", - source: "system", - }) - } + const mod = await import("./runtime.queue") + await mod.runPromptQueue({ + footer, + initialInput: input.initialInput, + trace: log, + onPrompt: () => { + shown = true + }, + run: async (prompt, signal) => { + if (demo && (await demo.prompt(prompt, signal))) { + return + } - if (input.demo) { - await ensureSession() - demo = createRunDemo({ - mode: input.demo, - text: input.demoText, - footer, - sessionID, - thinking: input.thinking, - limits: () => limits, - }) - } + return withRunSpan( + "RunInteractive.turn", + { + "opencode.agent.name": agent, + "opencode.model.provider": ctx.model?.providerID, + "opencode.model.id": ctx.model?.modelID, + "opencode.model.variant": activeVariant, + "opencode.prompt.chars": prompt.text.length, + "opencode.prompt.parts": prompt.parts.length, + "opencode.prompt.include_files": includeFiles, + "opencode.prompt.file_parts": includeFiles ? input.files.length : 0, + "session.id": sessionID || undefined, + }, + async (span) => { + try { + const next = await ensureStream() + setRunSpanAttributes(span, { + "opencode.agent.name": agent, + "opencode.model.variant": activeVariant, + "session.id": sessionID || undefined, + }) + await next.handle.runPromptTurn({ + agent, + model: ctx.model, + variant: activeVariant, + prompt, + files: input.files, + includeFiles, + signal, + }) + includeFiles = false + } catch (error) { + if (signal.aborted || footer.isClosed) { + return + } - if (input.afterPaint) { - void Promise.resolve(input.afterPaint(ctx)).catch(() => {}) - } - - void modelTask.then((info) => { - variants = info.variants - limits = info.limits - - const next = resolveVariant(ctx.variant, session.variant, savedVariant, variants) - if (next === activeVariant) { - return - } - - activeVariant = next - if (!ctx.model || footer.isClosed) { - return - } - - footer.event({ - type: "model", - model: formatModelLabel(ctx.model, activeVariant), - }) - }) - - const streamTask = import("./stream.transport") - let stream: StreamState | undefined - const loading: { current?: Promise } = {} - const ensureStream = () => { - if (stream) { - return Promise.resolve(stream) - } - - return reusePendingTask(loading, async () => { - await ensureSession() - if (footer.isClosed) { - throw new Error("runtime closed") + recordRunSpanError(span, error) + const text = + stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error)) + footer.append({ kind: "error", text, phase: "start", source: "system" }) + } + }, + ) + }, + }) } - const mod = await streamTask - if (footer.isClosed) { - throw new Error("runtime closed") - } + try { + const eager = ctx.resume === true || !input.resolveSession || !!input.demo + if (eager) { + await ensureStream() + } - const handle = await mod.createSessionTransport({ - sdk: ctx.sdk, - sessionID, - thinking: input.thinking, - limits: () => limits, - footer, - trace: log, - }) - if (footer.isClosed) { - await handle.close() - throw new Error("runtime closed") - } + if (!eager && input.resolveSession) { + queueMicrotask(() => { + if (footer.isClosed) { + return + } - selectSubagent = handle.selectSubagent - const next = { mod, handle } - stream = next - return next - }) - } - - const runQueue = async () => { - let includeFiles = true - if (demo) { - await demo.start() - } - - const mod = await import("./runtime.queue") - await mod.runPromptQueue({ - footer, - initialInput: input.initialInput, - trace: log, - onPrompt: () => { - shown = true - }, - run: async (prompt, signal) => { - if (demo && (await demo.prompt(prompt, signal))) { - return + void ensureStream().catch(() => {}) + }) } try { - const next = await ensureStream() - await next.handle.runPromptTurn({ - agent, - model: ctx.model, - variant: activeVariant, - prompt, - files: input.files, - includeFiles, - signal, - }) - includeFiles = false - } catch (error) { - if (signal.aborted || footer.isClosed) { - return - } - - const text = - stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error)) - footer.append({ kind: "error", text, phase: "start", source: "system" }) + await runQueue() + } finally { + await stream?.handle.close() } - }, - }) - } + } finally { + const title = + shown && hasSession + ? await ctx.sdk.session + .get({ + sessionID, + }) + .then((x) => x.data?.title) + .catch(() => undefined) + : undefined - try { - const eager = ctx.resume === true || !input.resolveSession || !!input.demo - if (eager) { - await ensureStream() - } - - if (!eager && input.resolveSession) { - queueMicrotask(() => { - if (footer.isClosed) { - return - } - - void ensureStream().catch(() => {}) - }) - } - - try { - await runQueue() - } finally { - await stream?.handle.close() - } - } finally { - const title = - shown && hasSession - ? await ctx.sdk.session - .get({ - sessionID, - }) - .then((x) => x.data?.title) - .catch(() => undefined) - : undefined - - await shell.close({ - showExit: shown && hasSession, - sessionTitle: title, - sessionID, - }) - } + await shell.close({ + showExit: shown && hasSession, + sessionTitle: title, + sessionID, + }) + } + }, + ) } // Local in-process mode. Creates an SDK client backed by a direct fetch to // the in-process server, so no external HTTP server is needed. export async function runInteractiveLocalMode(input: RunLocalInput): Promise { - const sdk = createOpencodeClient({ - baseUrl: "http://opencode.internal", - fetch: input.fetch, - directory: input.directory, - }) - let pending: Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> | undefined - - return runInteractiveRuntime({ - files: input.files, - initialInput: input.initialInput, - thinking: input.thinking, - demo: input.demo, - demoText: input.demoText, - resolveSession: () => { - if (pending) { - return pending - } - - pending = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, session]) => { - if (!session?.id) { - throw new Error("Session not found") - } - - void input.share(sdk, session.id).catch(() => {}) - return { - sessionID: session.id, - sessionTitle: session.title, - agent, - } - }) - return pending + return withRunSpan( + "RunInteractive.localMode", + { + "opencode.directory": input.directory, + "opencode.initial_input": !!input.initialInput, + "opencode.demo": input.demo, }, - boot: async () => { - return { - sdk, + async () => { + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: input.fetch, directory: input.directory, - sessionID: "", - sessionTitle: undefined, - resume: false, - agent: input.agent, - model: input.model, - variant: input.variant, - } + }) + let pending: Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> | undefined + + return runInteractiveRuntime({ + files: input.files, + initialInput: input.initialInput, + thinking: input.thinking, + demo: input.demo, + demoText: input.demoText, + resolveSession: () => { + if (pending) { + return pending + } + + pending = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, session]) => { + if (!session?.id) { + throw new Error("Session not found") + } + + void input.share(sdk, session.id).catch(() => {}) + return { + sessionID: session.id, + sessionTitle: session.title, + agent, + } + }) + return pending + }, + boot: async () => { + return { + sdk, + directory: input.directory, + sessionID: "", + sessionTitle: undefined, + resume: false, + agent: input.agent, + model: input.model, + variant: input.variant, + } + }, + }) }, - }) + ) } // Attach mode. Uses the caller-provided SDK client directly. export async function runInteractiveMode(input: RunInput): Promise { - return runInteractiveRuntime({ - files: input.files, - initialInput: input.initialInput, - thinking: input.thinking, - demo: input.demo, - demoText: input.demoText, - boot: async () => ({ - sdk: input.sdk, - directory: input.directory, - sessionID: input.sessionID, - sessionTitle: input.sessionTitle, - resume: input.resume, - agent: input.agent, - model: input.model, - variant: input.variant, - }), - }) + return withRunSpan( + "RunInteractive.attachMode", + { + "opencode.directory": input.directory, + "opencode.initial_input": !!input.initialInput, + "session.id": input.sessionID, + }, + async () => + runInteractiveRuntime({ + files: input.files, + initialInput: input.initialInput, + thinking: input.thinking, + demo: input.demo, + demoText: input.demoText, + boot: async () => ({ + sdk: input.sdk, + directory: input.directory, + sessionID: input.sessionID, + sessionTitle: input.sessionTitle, + resume: input.resume, + agent: input.agent, + model: input.model, + variant: input.variant, + }), + }), + ) } diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts index 39593a3d78..48041edcdb 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts +++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts @@ -14,6 +14,7 @@ import { type ScrollbackSurface, } from "@opentui/core" import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body" +import { withRunSpan } from "./otel" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer" import { type RunTheme } from "./theme" @@ -66,6 +67,7 @@ export class RunScrollbackStream { private active: ActiveEntry | undefined private wrote: boolean private diffStyle: RunDiffStyle | undefined + private sessionID?: () => string | undefined private treeSitterClient: TreeSitterClient | undefined constructor( @@ -74,11 +76,13 @@ export class RunScrollbackStream { options: { wrote?: boolean diffStyle?: RunDiffStyle + sessionID?: () => string | undefined treeSitterClient?: TreeSitterClient } = {}, ) { this.wrote = options.wrote ?? true this.diffStyle = options.diffStyle + this.sessionID = options.sessionID this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient() } @@ -282,7 +286,17 @@ export class RunScrollbackStream { } public async complete(trailingNewline = false): Promise { - await this.finishActive(trailingNewline) + return withRunSpan( + "RunScrollbackStream.complete", + { + "opencode.entry.active": !!this.active, + "opencode.trailing_newline": trailingNewline, + "session.id": this.sessionID?.() || undefined, + }, + async () => { + await this.finishActive(trailingNewline) + }, + ) } public destroy(): void { diff --git a/packages/opencode/test/cli/run/footer.test.ts b/packages/opencode/test/cli/run/footer.test.ts index ac47f81b31..62b087cec9 100644 --- a/packages/opencode/test/cli/run/footer.test.ts +++ b/packages/opencode/test/cli/run/footer.test.ts @@ -22,6 +22,7 @@ function createFooter(renderer: TestRenderer) { findFiles: async () => [], agents: [], resources: [], + sessionID: () => "session-1", agentLabel: "Build", modelLabel: "Model default", first: false,