diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a953653cd..a00b5f051 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -90,6 +90,50 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) + // altimate_change start (AI-7519) — first-answer latency instrumentation + + // user-facing phase label. + // + // Wraps an awaited operation with a Tracer span so bootstrap sub-steps are + // visible in session traces. Every wrapped await opens a discrete span so a + // future regression that adds a slow await also shows up automatically. + // + // On top of the tracing, publish a session.phase event (start on entry, end + // on exit) so the TUI can render an honest label like "Discovering + // warehouse tools..." during the pre-first-visible-response window. This is + // the SLO half of AI-7519 — target <10s to first *visible* response. The + // instrumentation names double as user-facing signal. + // + // The trace span is a sibling of the root (tracing.ts:1009 assigns + // parentSpanId to rootSpanId), not a nested child — good enough for + // waterfall correlation via timestamps, and no schema change is required. + async function traceSpan( + name: string, + fn: () => Promise, + input?: unknown, + sessionID?: SessionID, + ): Promise { + const startTime = Date.now() + if (sessionID) void SessionStatus.publishPhase(sessionID, name, true) + try { + const result = await fn() + Tracer.active?.logSpan({ name, startTime, endTime: Date.now(), input }) + return result + } catch (e) { + Tracer.active?.logSpan({ + name, + startTime, + endTime: Date.now(), + status: "error", + input, + output: { error: String(e) }, + }) + throw e + } finally { + if (sessionID) void SessionStatus.publishPhase(sessionID, name, false) + } + } + // altimate_change end + // altimate_change start — single source of truth for legacy agent-name normalization // // The "build" agent was renamed to "builder" but some persisted sessions and @@ -359,17 +403,61 @@ export namespace SessionPrompt { let structuredOutput: unknown | undefined let step = 0 - const session = await Session.get(sessionID) - // altimate_change start - detect environment fingerprint at session start - const altCfg = await Config.get() - if (altCfg.experimental?.env_fingerprint_skill_selection === true) { - await Fingerprint.detect(Instance.directory, Instance.worktree).catch((e) => { - log.warn("fingerprint detection failed", { error: e }) - }) + // altimate_change start (AI-7519) — capture bootstrap start; emitted as a + // single "bootstrap" span right before the first processor.process call so + // the pre-first-generation region has a visible parent duration in traces. + const bootstrapStart = Date.now() + // Enter busy state BEFORE the first bootstrap traceSpan fires so the + // phase labels the TUI renders are actually visible during + // session-get / config-get / fingerprint-detect / telemetry-init. The + // TUI's status renderer gates on `status.type === "busy"`; without + // this early set only `bootstrap.resolve-tools` (which fires inside + // the while-loop after the existing busy set at line 506) would show + // a label. The while-loop re-set below is now a no-op busy → busy + // transition, preserved for legacy call sites that may enter the + // loop from elsewhere. + await SessionStatus.set(sessionID, { type: "busy" }) + // altimate_change end + // altimate_change start (AI-7519) — discharge the busy status if a bootstrap + // await throws. cancel() (prompt.ts:364) deliberately does NOT set idle + // when the state entry still exists — it relies on the processor's catch + // block for that. But during bootstrap the processor hasn't taken over + // yet, so a throw here (Session.get / Config.get / Fingerprint.detect / + // Telemetry.init) would leave the session permanently `busy` with no + // idle/error transition. Reset to idle on any bootstrap failure and + // re-throw so callers still see the error. + let session: Awaited> + let altCfg: Awaited> + try { + session = await traceSpan( + "bootstrap.session-get", + () => Session.get(sessionID), + { sessionID }, + sessionID, + ) + // altimate_change start - detect environment fingerprint at session start + altCfg = await traceSpan("bootstrap.config-get", () => Config.get(), undefined, sessionID) + if (altCfg.experimental?.env_fingerprint_skill_selection === true) { + await traceSpan( + "bootstrap.fingerprint-detect", + () => Fingerprint.detect(Instance.directory, Instance.worktree), + undefined, + sessionID, + ).catch((e) => { + log.warn("fingerprint detection failed", { error: e }) + }) + } + // altimate_change end + // altimate_change start — session telemetry tracking + await traceSpan("bootstrap.telemetry-init", () => Telemetry.init(), undefined, sessionID) + } catch (e) { + // Best-effort transition to idle so the TUI spinner + any busy-gated + // callers don't stay stuck. Swallow inner failure so the original + // bootstrap error is what bubbles out. + await SessionStatus.set(sessionID, { type: "idle" }).catch(() => {}) + throw e } // altimate_change end - // altimate_change start — session telemetry tracking - await Telemetry.init() Telemetry.setContext({ sessionId: sessionID, projectId: Instance.project?.id ?? "" }) const sessionStartTime = Date.now() let sessionTotalCost = 0 @@ -913,15 +1001,28 @@ export namespace SessionPrompt { const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - const tools = await resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor, - bypassAgentCheck, - messages: msgs, - }) + // altimate_change start (AI-7519) — trace resolveTools per step. + // Included in the parent `bootstrap` span on step===1; on later steps + // this measures the per-turn tool-listing overhead (MCP.tools connect + // cost etc.). Distinct span name per phase so telemetry doesn't + // double-count non-bootstrap turns under "bootstrap.*", and the TUI + // falls back to the safe "Thinking..." label on later turns. + const tools = await traceSpan( + step === 1 ? "bootstrap.resolve-tools" : "turn.resolve-tools", + () => + resolveTools({ + agent, + session, + model, + tools: lastUser.tools, + processor, + bypassAgentCheck, + messages: msgs, + }), + { step, agent: agent.name }, + sessionID, + ) + // altimate_change end // Inject StructuredOutput tool if JSON schema mode enabled if (lastUser.format?.type === "json_schema") { @@ -1070,6 +1171,25 @@ export namespace SessionPrompt { input: { agent: agent.name, step }, output: { parts: system.length, content: system.join("\n\n") }, }) + // altimate_change start (AI-7519) — emit the parent bootstrap span + // covering everything from loop() entry to just-before-first-generation. + // Companion sub-spans (bootstrap.session-get, bootstrap.config-get, + // bootstrap.resolve-tools, etc.) are already emitted; this span gives + // the waterfall a single top-level duration to render + gate against. + // Capture endTime once so duration_ms is guaranteed to match — two + // Date.now() calls can straddle a clock tick. + const bootstrapEnd = Date.now() + Tracer.active?.logSpan({ + name: "bootstrap", + startTime: bootstrapStart, + endTime: bootstrapEnd, + input: { agent: agent.name, sessionID }, + output: { + duration_ms: bootstrapEnd - bootstrapStart, + system_parts: system.length, + }, + }) + // altimate_change end } // altimate_change end diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 5bf2141f5..c49b340bb 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -53,6 +53,22 @@ export const Event = { sessionID: SessionID, }, }), + // altimate_change start (AI-7519) — session.phase carries the currently-active bootstrap or per-turn + // sub-step name so the TUI can render an honest "Loading config..." / "Discovering tools..." label + // during the invisible pre-first-visible-response window (target <10s to first visible response). + Phase: EventV2.define({ + type: "session.phase", + schema: { + sessionID: SessionID, + // Sub-span name from the traceSpan wrapper — e.g. "bootstrap.resolve-tools". The TUI maps + // this to a human label; unknown phases fall back to "Thinking...". + phase: Schema.String, + // true when the phase opens, false when it closes. TUI clears the label on close if it + // matches the currently-rendered phase. + active: Schema.Boolean, + }, + }), + // altimate_change end } // altimate_change start - mirror status events onto the legacy Bus SSE stream @@ -71,6 +87,16 @@ const LegacyEvent = { sessionID: SessionID.zod, }), ), + // altimate_change start (AI-7519) — legacy SSE mirror for the phase event + Phase: BusEvent.define( + "session.phase", + z.object({ + sessionID: SessionID.zod, + phase: z.string(), + active: z.boolean(), + }), + ), + // altimate_change end } // altimate_change end @@ -78,6 +104,11 @@ export interface Interface { readonly get: (sessionID: SessionID) => Effect.Effect readonly list: () => Effect.Effect> readonly set: (sessionID: SessionID, status: Info) => Effect.Effect + // altimate_change start (AI-7519) — publish a session.phase event through + // the EventV2 bus so V2 subscribers see phase transitions alongside the + // legacy bus mirror. + readonly publishPhase: (sessionID: SessionID, phase: string, active: boolean) => Effect.Effect + // altimate_change end } export class Service extends Context.Service()("@opencode/SessionStatus") {} @@ -111,7 +142,19 @@ export const layer = Layer.effect( data.set(sessionID, status) }) - return Service.of({ get, list, set }) + // altimate_change start (AI-7519) — V2 publish for the session.phase event. + // Complements the LegacyEvent.Phase publish that happens in the imperative + // wrapper below so both V1 (SSE mirror) and V2 subscribers see phases. + const publishPhase = Effect.fn("SessionStatus.publishPhase")(function* ( + sessionID: SessionID, + phase: string, + active: boolean, + ) { + yield* events.publish(Event.Phase, { sessionID, phase, active }) + }) + // altimate_change end + + return Service.of({ get, list, set, publishPhase }) }), ) @@ -142,4 +185,27 @@ export async function list() { } // altimate_change end +// altimate_change start (AI-7519) — publish a session.phase event. Fired by SessionPrompt.traceSpan +// on entry (active=true) and exit (active=false); the TUI subscribes to render an honest label like +// "Discovering warehouse tools..." during the pre-first-visible-response window. Publishes on both +// the EventV2 bus (for V2 subscribers) and the LegacyEvent.Phase bus (for the SSE mirror the TUI +// consumes). Best-effort: any failure here must not affect the traced operation itself. The two +// publishes are intentionally sequential (V2 first, legacy second) — an earlier attempt to +// parallelise them via `Promise.allSettled` produced intermittent e2e failures where the label +// wouldn't render, likely because the first ManagedRuntime warm-up races with the immediate +// legacy Bus.publish. Sequential is reliable + the cost is negligible on the hot path. +export async function publishPhase(sessionID: SessionID, phase: string, active: boolean) { + try { + await runStatus((s) => s.publishPhase(sessionID, phase, active)) + } catch { + // never surface phase-publish failures back to the caller + } + try { + await Bus.publish(LegacyEvent.Phase, { sessionID, phase, active }) + } catch { + // never surface phase-publish failures back to the caller + } +} +// altimate_change end + export * as SessionStatus from "./status" diff --git a/packages/opencode/test/cli/tui/phase-label.tui-e2e.test.ts b/packages/opencode/test/cli/tui/phase-label.tui-e2e.test.ts new file mode 100644 index 000000000..54df46e58 --- /dev/null +++ b/packages/opencode/test/cli/tui/phase-label.tui-e2e.test.ts @@ -0,0 +1,93 @@ +/** + * TUI e2e — AI-7519: phase-label renders during the busy pre-first-visible-response window. + * + * The <10s-to-first-visible-response half of AI-7519 relies on the server + * publishing session.phase events (fired by SessionPrompt.traceSpan on entry + * and exit) and the TUI subscribing + rendering an honest label like + * "Discovering tools..." or the "Thinking..." fallback next to the busy + * spinner. Every static check + unit test can pass while the actual TUI + * render silently drops the label — the AI-6298 experience demonstrated + * exactly that failure mode. This spec drives the real TUI end-to-end so the + * label is verified against the user's actual view. + * + * Two hard assertions: + * 1. The "Thinking..." fallback renders during any busy window — proves + * the render chain (status → phaseLabel → spinner row) is intact. + * 2. A specific mapped label ("Discovering tools") renders somewhere in + * the run — proves the full phase-event pipeline (publishPhase → + * Bus.publish → sync.tsx handler → store update → render) is intact + * end-to-end. Without this, a regression that entirely broke phase + * publishing would still green here because the fallback renders + * regardless. + * + * "Discovering tools" is the label mapped from `bootstrap.resolve-tools`, + * whose span duration (~30-100ms while listing MCP tools) is comfortably + * larger than the PTY harness poll interval (50ms). The other bootstrap + * sub-spans are sub-millisecond and observed to not survive PTY sampling — + * a diagnostic dump of all specific label hits is retained for future + * timing-driven debugging. + */ + +import { describe, expect, test } from "bun:test" +import { launchTui } from "../../fixture/pty-tui" +import { tmpdir } from "../../fixture/fixture" + +describe("TUI e2e — AI-7519 phase-label render", () => { + test("phase-label pipeline renders 'Discovering tools' + 'Thinking...' fallback beside the busy spinner", async () => { + // await using ensures the temp directory is cleaned up even if launchTui + // rejects before the try block — matches the codebase convention in + // scheduler.test.ts and the coding guideline enforced by CI. + await using tmp = await tmpdir() + const tui = await launchTui({ + cwd: tmp.path, + cols: 140, + rows: 50, + waitForReady: "Ask anything", + }) + try { + // Type a short prompt and submit it. The exact content doesn't matter — + // we're driving the session-prompt loop() to fire, which runs the + // bootstrap traceSpan wrappers that publish session.phase events. The + // loop() runs before any LLM call, so this works even if the machine + // has no provider auth configured — the phase labels fire during + // bootstrap, and the busy state is entered before the eventual error + // banner (if any) resolves. + for (const ch of "hi") { + tui.write(ch) + await new Promise((r) => setTimeout(r, 25)) + } + tui.sendKey("Enter") + + // (1) Fallback assertion — proves the render chain is alive. + // phaseLabel(undefined) renders "Thinking..." during any busy window + // without a specific phase name mapped. + await tui.waitForText(/Thinking\.\.\./, { timeoutMs: 8000 }) + + // (2) Mapped-label assertion — proves the full phase pipeline is alive. + // "Discovering tools" is the label for `bootstrap.resolve-tools`, which + // fires on step===1 inside loop(). Its span (MCP tool listing) is + // reliably longer than the PTY poll interval. Regression that breaks + // publishPhase / Bus.publish / sync handler / store update would fail + // this assertion even though the fallback above still succeeds. + await tui.waitForText("Discovering tools", { timeoutMs: 8000 }) + + // Diagnostic dump of any other mapped labels that landed — helpful for + // future timing-related debugging without gating the test on + // sub-millisecond span durations we can't reliably observe over PTY. + const rendered = tui.text() + const specificLabels = { + "Loading session": rendered.includes("Loading session"), + "Loading config": rendered.includes("Loading config"), + "Discovering tools": rendered.includes("Discovering tools"), + "Preparing telemetry": rendered.includes("Preparing telemetry"), + "Detecting project shape": rendered.includes("Detecting project shape"), + } + // eslint-disable-next-line no-console + console.log("[AI-7519 e2e] specific label hits:", JSON.stringify(specificLabels)) + expect(rendered).toContain("Thinking...") + expect(rendered).toContain("Discovering tools") + } finally { + await tui.dispose() + } + }, 45_000) +}) diff --git a/packages/opencode/test/fixture/pty-tui.ts b/packages/opencode/test/fixture/pty-tui.ts new file mode 100644 index 000000000..f5339d76a --- /dev/null +++ b/packages/opencode/test/fixture/pty-tui.ts @@ -0,0 +1,252 @@ +/** + * TUI e2e test harness. + * + * Spawns the altimate-code TUI inside a pseudo-terminal so tests can drive it + * via keystrokes and assert on rendered output. Built on `bun-pty` (already a + * runtime dependency for the production PTY layer) and `strip-ansi` for + * matching against the visible-text projection of the captured stream. + * + * Coverage goal: behaviors that depend on the actual rendered TUI — keystroke + * bindings, dialog flows, dropdowns, focus management. For pure picker-data + * questions (e.g. "is `claude-opus-4-7` in `Provider.list()`?") the cheaper + * `Provider.list()` / `GET /config/providers` tests already exist. + * + * Typical shape: + * const tui = await launchTui({ cwd: tmp.path }) + * try { + * await tui.waitForText("ready") + * tui.sendKey("Ctrl-X") + * tui.write("m") + * await tui.waitForText("Snowflake Cortex", { timeoutMs: 5000 }) + * expect(tui.text()).toMatch(/claude-opus-4-7/) + * } finally { + * await tui.dispose() + * } + */ + +import path from "path" +// altimate_change start — upstream v1.17.9 dropped bun-pty from packages/opencode; +// the fork's runtime PTY layer now imports from @opencode-ai/core/pty/pty.bun. Follow +// the same rewire here so the harness compiles on v0.9.0-beta.2 without adding +// bun-pty back as a devDependency. +import { spawn as ptySpawn } from "@opencode-ai/core/pty/pty.bun" +// altimate_change end +import stripAnsi from "strip-ansi" + +const INDEX_TS = path.resolve(__dirname, "../../src/index.ts") +const PACKAGE_ROOT = path.resolve(__dirname, "../..") +const BUN = process.execPath + +export const SPECIAL_KEYS = { + Enter: "\r", + Tab: "\t", + Escape: "\x1b", + Backspace: "\x7f", + Up: "\x1b[A", + Down: "\x1b[B", + Right: "\x1b[C", + Left: "\x1b[D", + "Ctrl-C": "\x03", + "Ctrl-D": "\x04", + "Ctrl-X": "\x18", + "Ctrl-A": "\x01", + "Ctrl-M": "\r", + "Ctrl-N": "\x0e", + "Ctrl-P": "\x10", +} as const +export type KeyName = keyof typeof SPECIAL_KEYS + +export type LaunchOptions = { + /** + * Project directory to pass to the TUI as its positional `[project]` + * argument. This becomes the workspace the TUI operates against — not the + * cwd of the child process. The child runs with cwd=`packages/opencode/` + * so Bun resolves the OpenTUI/Solid JSX runtime via the package's tsconfig. + */ + cwd: string + /** Extra argv after the entry path. Default `[]`. The harness already passes the project dir. */ + args?: string[] + /** Terminal columns. Default 120. */ + cols?: number + /** Terminal rows. Default 40. */ + rows?: number + /** Extra environment variables to merge into the spawned process. */ + env?: Record + /** Initial waitForText boot timeout in ms. Default 15_000. */ + bootTimeoutMs?: number + /** + * If set, harness waits for this text before returning. Default `undefined` + * (caller is responsible for the first wait). + */ + waitForReady?: string +} + +export type TuiSession = { + /** Process id of the PTY child. */ + pid: number + /** Write raw text to stdin. */ + write(data: string): void + /** Send a named key. Throws on unknown name. */ + sendKey(key: KeyName): void + /** Captured stdout, ANSI escapes stripped. Includes the entire stream so far. */ + text(): string + /** Captured stdout with ANSI preserved (useful for debugging on assertion failure). */ + rawText(): string + /** + * Resolve when `needle` (string or regex) appears in the stripped output. + * Rejects with a descriptive error on timeout. Polls every ~50ms. + */ + waitForText(needle: string | RegExp, opts?: { timeoutMs?: number }): Promise + /** Resize the pseudo-terminal. */ + resize(cols: number, rows: number): void + /** Kill the child + remove listeners. Idempotent. */ + dispose(): Promise + /** Exit-code promise — resolves when child exits. */ + exited: Promise<{ exitCode: number; signal?: number | string }> +} + +const DEFAULT_BOOT_TIMEOUT_MS = 15_000 +const DEFAULT_WAIT_TIMEOUT_MS = 5_000 +const POLL_INTERVAL_MS = 50 + +export async function launchTui(opts: LaunchOptions): Promise { + const cols = opts.cols ?? 120 + const rows = opts.rows ?? 40 + // Pass the project directory as the TUI's positional [project] argument. + // Child cwd must remain inside the package so Bun resolves the OpenTUI/Solid + // JSX import source via packages/opencode/tsconfig.json — without that, the + // child crashes with `Cannot find module 'react/jsx-dev-runtime'`. + const args = ["run", INDEX_TS, opts.cwd, ...(opts.args ?? [])] + + const env: Record = { + ...(process.env as Record), + // Force TTY-shaped behavior + colorless rendering where possible to keep + // ANSI noise low. The TUI still emits its own SGR escapes; strip-ansi + // handles those. + TERM: "xterm-256color", + FORCE_COLOR: "0", + // Disable analytics in tests. + OPENCODE_DISABLE_TELEMETRY: "1", + ...opts.env, + } + + const child = ptySpawn(BUN, args, { + name: "xterm-256color", + cols, + rows, + cwd: PACKAGE_ROOT, + env, + }) + + let raw = "" + let stripped = "" + child.onData((data) => { + const chunk = data.toString() + raw += chunk + stripped += stripAnsi(chunk) + }) + + let hasExited = false + const exited = new Promise<{ exitCode: number; signal?: number | string }>((resolve) => { + child.onExit((event) => { + hasExited = true + resolve(event) + }) + }) + + let disposed = false + + const session: TuiSession = { + pid: child.pid, + write(data) { + if (disposed) return + child.write(data) + }, + sendKey(key) { + const seq = SPECIAL_KEYS[key] + if (seq === undefined) throw new Error(`unknown key: ${key}`) + if (disposed) return + child.write(seq) + }, + text() { + return stripped + }, + rawText() { + return raw + }, + resize(c, r) { + if (disposed) return + child.resize(c, r) + }, + async waitForText(needle, waitOpts) { + const timeoutMs = waitOpts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS + const deadline = Date.now() + timeoutMs + const matches = (s: string) => { + if (typeof needle === "string") return s.includes(needle) + // Reset lastIndex so a RegExp with the `g` flag (which makes .test() + // stateful) still returns consistently across successive polls. + needle.lastIndex = 0 + return needle.test(s) + } + if (matches(stripped)) return + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)) + if (matches(stripped)) return + // Fail fast if the child exited before the needle showed up — + // otherwise a crash silently burns the full timeout budget and + // masks the underlying cause. + if (hasExited) { + throw new Error( + `waitForText saw child exit before matching ${ + typeof needle === "string" ? JSON.stringify(needle) : needle.toString() + }\n\n--- captured (stripped, last 4000 chars) ---\n${stripped.slice(-4000)}\n--- end ---`, + ) + } + } + throw new Error( + `waitForText timed out after ${timeoutMs}ms waiting for ${ + typeof needle === "string" ? JSON.stringify(needle) : needle.toString() + }\n\n--- captured (stripped, last 4000 chars) ---\n${stripped.slice(-4000)}\n--- end ---`, + ) + }, + async dispose() { + if (disposed) return + disposed = true + try { + child.kill("SIGTERM") + } catch { + // already gone + } + // Give the process a beat to exit cleanly, then escalate to SIGKILL if it + // hasn't. A child that traps/ignores SIGTERM (or is slow to tear down its + // render loop + worker) would otherwise be left orphaned when dispose() + // returns — a silent test-process leak on CI. + const exitedCleanly = await Promise.race([ + exited.then(() => true), + new Promise((r) => setTimeout(() => r(false), 1000)), + ]) + if (!exitedCleanly) { + try { + child.kill("SIGKILL") + } catch { + // already gone + } + await Promise.race([exited, new Promise((r) => setTimeout(r, 500))]) + } + }, + exited, + } + + if (opts.waitForReady !== undefined) { + try { + await session.waitForText(opts.waitForReady, { + timeoutMs: opts.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS, + }) + } catch (err) { + await session.dispose() + throw err + } + } + + return session +} diff --git a/packages/opencode/test/upstream/fork-feature-guards.test.ts b/packages/opencode/test/upstream/fork-feature-guards.test.ts index 0582d9fe8..7fdcc4133 100644 --- a/packages/opencode/test/upstream/fork-feature-guards.test.ts +++ b/packages/opencode/test/upstream/fork-feature-guards.test.ts @@ -211,4 +211,41 @@ describe("fork feature presence guards (merge drop detection)", () => { expect(session).toMatch(/findIndex\(\(x\) => x\.id === session\(\)\?\.id\) \+ direction/) expect(session).not.toMatch(/findIndex\(\(x\) => x\.id === session\(\)\?\.id\) - direction/) }) + + // AI-7519: the session.phase event pipeline is a fork feature — server publishes on traceSpan + // entry/exit, TUI subscribes and renders an honest "Discovering tools..." style label during + // the pre-first-visible-response window. Every piece is load-bearing for the <10s SLO half of + // the ticket; a merge silently dropping any of them turns the label back into a silent spinner. + test("AI-7519: session.phase event pipeline is wired (publish + subscribe + render)", async () => { + const status = await read("src/session/status.ts") + expect(status).toMatch(/Phase:\s*EventV2\.define\(/) + expect(status).toMatch(/type:\s*"session\.phase"/) + expect(status).toMatch(/export async function publishPhase/) + + const prompt = await read("src/session/prompt.ts") + expect(prompt).toMatch(/function traceSpan\([\s\S]{0,200}sessionID\?: SessionID/) + expect(prompt).toMatch(/SessionStatus\.publishPhase\(sessionID, name, true\)/) + expect(prompt).toMatch(/SessionStatus\.publishPhase\(sessionID, name, false\)/) + expect(prompt).toMatch(/"bootstrap\.session-get",[\s\S]{0,120}sessionID/) + expect(prompt).toMatch(/"bootstrap\.config-get",[\s\S]{0,120}sessionID/) + // resolve-tools span name is step-aware — "bootstrap.resolve-tools" on + // step===1, "turn.resolve-tools" on subsequent steps so telemetry doesn't + // over-count bootstrap operations. + expect(prompt).toMatch(/"bootstrap\.resolve-tools"\s*:\s*"turn\.resolve-tools"/) + expect(prompt).toMatch(/step\s*===\s*1[\s\S]{0,200}resolve-tools[\s\S]{0,400}sessionID/) + + const sync = await read("src/context/sync.tsx", MONO + "/tui") + expect(sync).toMatch(/session_phase:\s*\{/) + expect(sync).toMatch(/case "session\.phase":/) + expect(sync).toMatch(/setStore\("session_phase"/) + + const phaseLabelSrc = await read("src/util/phase-label.ts", MONO + "/tui") + expect(phaseLabelSrc).toMatch(/export function phaseLabel/) + expect(phaseLabelSrc).toMatch(/"bootstrap\.session-get"/) + expect(phaseLabelSrc).toMatch(/"bootstrap\.resolve-tools"/) + + const promptTsx = await read("src/component/prompt/index.tsx", MONO + "/tui") + expect(promptTsx).toMatch(/import\s*\{\s*phaseLabel\s*\}\s*from\s*"[^"]*phase-label"/) + expect(promptTsx).toMatch(/phaseLabel\(phase\(\)\)/) + }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e2add116e..dbcd2e022 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -79,6 +79,7 @@ export type Event = | EventProjectUpdated | EventSessionStatus | EventSessionIdle + | EventSessionPhase | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1529,6 +1530,15 @@ export type GlobalEvent = { sessionID: string } } + | { + id: string + type: "session.phase" + properties: { + sessionID: string + phase: string + active: boolean + } + } | { id: string type: "question.asked" @@ -5038,6 +5048,16 @@ export type EventSessionIdle = { } } +export type EventSessionPhase = { + id: string + type: "session.phase" + properties: { + sessionID: string + phase: string + active: boolean + } +} + export type EventQuestionAsked = { id: string type: "question.asked" diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 176c27ec2..fcf45b0ad 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -28,6 +28,9 @@ import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" import { useExit } from "../../context/exit" +// altimate_change start (AI-7519) — phase → user-facing label lookup +import { phaseLabel } from "../../util/phase-label" +// altimate_change end import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "../../prompt/history" @@ -195,6 +198,12 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) + // altimate_change start (AI-7519) — active pre-first-visible phase for the current session. + // Backend publishes session.phase events during bootstrap and per-turn setup; this memo tracks + // the current one so the status renderer can show "Discovering tools..." etc. instead of a + // silent spinner. + const phase = createMemo(() => sync.data.session_phase?.[props.sessionID ?? ""]) + // altimate_change end const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() @@ -1612,6 +1621,15 @@ export function Prompt(props: PromptProps) { + {/* altimate_change start (AI-7519) — honest phase label during the busy + pre-first-visible-response window. Shows "Discovering tools..." / + "Loading config..." etc. driven by session.phase events, falls back to + "Thinking...". Retry state keeps its own message (below), so only render + when NOT in retry state. */} + + {phaseLabel(phase())} + + {/* altimate_change end */} {(() => { const retry = createMemo(() => { diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 586742b37..b52a73792 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -85,6 +85,13 @@ export const { session_status: { [sessionID: string]: SessionStatus } + // altimate_change start (AI-7519) — active pre-first-visible phase per session, + // e.g. "bootstrap.resolve-tools". Populated by session.phase events from the + // backend; consumed by the prompt status renderer to show an honest label. + session_phase: { + [sessionID: string]: string | undefined + } + // altimate_change end session_diff: { [sessionID: string]: SnapshotFileDiff[] } @@ -127,6 +134,9 @@ export const { provider_default: {}, session: [], session_status: {}, + // altimate_change start (AI-7519) + session_phase: {}, + // altimate_change end session_diff: {}, todo: {}, message: {}, @@ -358,6 +368,24 @@ export const { break } + // altimate_change start (AI-7519) — track the active bootstrap / per-turn phase per + // session. `active=true` sets the phase; `active=false` clears it iff it's still the + // current phase (defensive against reordered events). + case "session.phase": { + const { sessionID, phase, active } = event.properties as { + sessionID: string + phase: string + active: boolean + } + if (active) { + setStore("session_phase", sessionID, phase) + } else if (store.session_phase[sessionID] === phase) { + setStore("session_phase", sessionID, undefined) + } + break + } + // altimate_change end + case "message.updated": { touchMessage(event.properties.info.sessionID, event.properties.info.id) // altimate_change start - line streaming: flush remaining buffer when message completes diff --git a/packages/tui/src/util/phase-label.ts b/packages/tui/src/util/phase-label.ts new file mode 100644 index 000000000..2a979993a --- /dev/null +++ b/packages/tui/src/util/phase-label.ts @@ -0,0 +1,26 @@ +// altimate_change start (AI-7519) — phase → user-facing label lookup. +// +// The backend publishes `session.phase` events keyed by internal span names +// (e.g. `bootstrap.resolve-tools`). This helper maps them to short honest +// labels the TUI renders next to the busy spinner during the pre-first-visible +// window (target: <10s to first visible response). +// +// Unknown phases fall back to "Thinking..." — a safe default that matches what +// Cursor / Claude Code / Codex CLI show. Add entries as new spans get wrapped +// with SessionPrompt.traceSpan. + +const PHASE_LABELS: Record = { + // bootstrap sub-steps that fire once at session start + "bootstrap.session-get": "Loading session...", + "bootstrap.config-get": "Loading config...", + "bootstrap.fingerprint-detect": "Detecting project shape...", + "bootstrap.telemetry-init": "Preparing telemetry...", + // per-turn (also runs on bootstrap step===1) + "bootstrap.resolve-tools": "Discovering tools...", +} + +export function phaseLabel(phase: string | undefined): string { + if (!phase) return "Thinking..." + return PHASE_LABELS[phase] ?? "Thinking..." +} +// altimate_change end