From 655adbf46e5f407d751ab8a899a9ba74ea795ca3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 26 Jun 2026 20:56:36 -0400 Subject: [PATCH] fix: stabilize v2 runtime behavior --- .github/workflows/test.yml | 1 + package.json | 2 +- packages/cli/src/commands/handlers/default.ts | 10 +- packages/cli/src/commands/handlers/serve.ts | 17 +- packages/cli/src/services/daemon.ts | 4 +- packages/cli/src/services/standalone.ts | 9 +- packages/cli/src/tui.ts | 10 +- packages/cli/test/fixture/standalone-owner.ts | 15 ++ packages/cli/test/standalone.test.ts | 65 ++++++ packages/core/src/plugin/internal.ts | 31 +-- packages/core/src/process.ts | 19 ++ packages/core/src/tool/bash.ts | 66 +++--- packages/core/src/tool/tool.ts | 34 ++- packages/core/test/process/process.test.ts | 16 ++ packages/core/test/tool-bash.test.ts | 80 +++++--- packages/plugin/src/tui.ts | 7 +- .../src/components/message-part.tsx | 12 +- packages/tui/src/app.tsx | 53 +++-- packages/tui/src/component/prompt/index.tsx | 15 +- packages/tui/src/context/data.tsx | 82 ++------ packages/tui/src/context/event.ts | 24 +-- packages/tui/src/context/local.tsx | 2 +- packages/tui/src/context/project.tsx | 12 +- packages/tui/src/context/sdk.tsx | 194 +++++++++++------- .../feature-plugins/system/notifications.ts | 44 ++-- packages/tui/src/routes/session/index.tsx | 65 +++--- packages/tui/src/routes/session/rows.ts | 5 +- packages/tui/src/util/collapse-tool-output.ts | 6 +- .../test/cli/cmd/tui/notifications.test.ts | 51 ++--- .../test/cli/tui/collapse-tool-output.test.ts | 14 ++ packages/tui/test/cli/tui/data.test.tsx | 54 +++-- .../tui/test/cli/tui/session-rows.test.ts | 33 +++ packages/tui/test/cli/tui/use-event.test.tsx | 90 ++++++-- packages/tui/test/fixture/tui-sdk.ts | 27 +-- turbo.json | 4 + 35 files changed, 745 insertions(+), 428 deletions(-) create mode 100644 packages/cli/test/fixture/standalone-owner.ts create mode 100644 packages/cli/test/standalone.test.ts create mode 100644 packages/tui/test/cli/tui/collapse-tool-output.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63027415b8c..7ddaa2d5132 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: - dev + - v2 pull_request: workflow_dispatch: diff --git a/package.json b/package.json index 49507128d60..3bc907d486e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "packageManager": "bun@1.3.14", "scripts": { - "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", + "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 3be065f03d3..0e55185b145 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -11,6 +11,14 @@ export default Runtime.handler(Commands, (input) => const daemon = yield* Daemon.Service const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport()) const { runTui } = yield* Effect.promise(() => import("../../tui")) - yield* runTui(transport) + yield* runTui( + transport, + input.standalone + ? undefined + : async () => { + await Effect.runPromise(daemon.stop()) + return Effect.runPromise(daemon.transport()) + }, + ) }), ) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 6b7141c1834..45922295697 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -32,12 +32,27 @@ export default Runtime.handler( if (input.register) yield* daemon.register(address) const url = HttpServer.formatAddress(address) console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) - return yield* Effect.never + return yield* (input.stdio ? waitForStdinClose() : Effect.never) }).pipe(Effect.annotateLogs({ role: "server" })), ) }), ) +function waitForStdinClose() { + return Effect.callback((resume) => { + const close = () => resume(Effect.void) + process.stdin.once("end", close) + process.stdin.once("close", close) + process.stdin.resume() + if (process.stdin.readableEnded || process.stdin.destroyed) close() + return Effect.sync(() => { + process.stdin.off("end", close) + process.stdin.off("close", close) + process.stdin.pause() + }) + }) +} + function listen(hostname: string, port: Option.Option, password: string) { if (Option.isSome(port)) return bind(hostname, port.value, password) const next = (port: number): ReturnType => diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 027f1782ac7..964822d1d2f 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -1,5 +1,5 @@ import { Global } from "@opencode-ai/core/global" -import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { ServerAuth } from "@opencode-ai/server/auth" import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect" @@ -41,7 +41,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const directory = Global.Path.state - const file = path.join(directory, "server.json") + const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json") const configFile = path.join(Global.Path.config, "service.json") const legacyPasswordFile = path.join(directory, "password") const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index 3c87fd5f90d..b17005e6d2f 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -16,9 +16,12 @@ function command(password: string) { cwd: process.cwd(), env: { OPENCODE_SERVER_PASSWORD: password }, extendEnv: true, - stdin: "ignore", + // The server treats EOF on this pipe as the end of its ownership lease. + // The OS closes it even when the TUI is killed before Effect finalizers run. + stdin: "pipe", stderr: "ignore", - killSignal: "SIGKILL", + killSignal: "SIGTERM", + forceKillAfter: "3 seconds", }) } @@ -30,7 +33,7 @@ export const transport = Effect.fn("cli.standalone.transport")( const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) const ready = yield* Effect.tryPromise(() => decodeReady(output)) - return { url: ready.url, headers: ServerAuth.headers({ password }) } + return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid } }, Effect.provide(CrossSpawnSpawner.defaultLayer), ) diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 92799b5050b..32a225265cc 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -5,7 +5,9 @@ import { Global } from "@opencode-ai/core/global" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { +type Transport = { url: string; headers: RequestInit["headers"] } + +export function runTui(transport: Transport, reload?: () => Promise) { const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { @@ -23,6 +25,12 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"] ) return yield* run({ client: createOpencodeClient({ ...options, directory }), + reload: reload + ? async () => { + const next = await reload() + return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }) + } + : undefined, args: {}, config, pluginHost: { diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts new file mode 100644 index 00000000000..6149f614de7 --- /dev/null +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import path from "node:path" +import { Standalone } from "../../src/services/standalone" + +process.argv[1] = path.join(import.meta.dir, "../../src/index.ts") + +await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const transport = yield* Standalone.transport() + console.log(`${transport.pid} ${transport.url}`) + return yield* Effect.never + }), + ), +) diff --git a/packages/cli/test/standalone.test.ts b/packages/cli/test/standalone.test.ts new file mode 100644 index 00000000000..1a309477f32 --- /dev/null +++ b/packages/cli/test/standalone.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test" +import path from "node:path" + +test("standalone server exits when its owner is killed", async () => { + const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], { + cwd: path.join(import.meta.dir, ".."), + env: process.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)]) + const [rawPID, url] = line?.split(" ") ?? [] + const pid = Number(rawPID) + + try { + expect(pid).toBeGreaterThan(0) + expect(url).toStartWith("http://127.0.0.1:") + expect(running(pid)).toBe(true) + + owner.kill("SIGKILL") + await owner.exited + + expect(await waitForExit(pid)).toBe(true) + } finally { + owner.kill("SIGKILL") + if (running(pid)) process.kill(pid, "SIGKILL") + } +}) + +async function readLine(stream: ReadableStream) { + const reader = stream.getReader() + const decoder = new TextDecoder() + const chunks: string[] = [] + while (true) { + const result = await reader.read() + if (result.done) break + chunks.push(decoder.decode(result.value, { stream: true })) + const output = chunks.join("") + const newline = output.indexOf("\n") + if (newline !== -1) { + reader.releaseLock() + return output.slice(0, newline) + } + } + reader.releaseLock() + return chunks.join("") + decoder.decode() +} + +async function waitForExit(pid: number, attempts = 100): Promise { + if (!running(pid)) return true + if (attempts === 0) return false + await Bun.sleep(50) + return waitForExit(pid, attempts - 1) +} + +function running(pid: number) { + if (!Number.isSafeInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 51c6e279b78..cb54e2c2161 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -23,6 +23,7 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { Reference } from "../reference" import { SkillV2 } from "../skill" +import { State } from "../state" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" @@ -102,20 +103,22 @@ export const locationLayer = Layer.effectDiscard( return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) } - yield* Effect.gen(function* () { - yield* add(ConfigReferencePlugin.Plugin) - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - for (const item of ProviderPlugins) yield* add(item) - yield* add(ConfigExternalPlugin.Plugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(VariantPlugin.Plugin) - }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + yield* State.batch( + Effect.gen(function* () { + yield* add(ConfigReferencePlugin.Plugin) + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(VariantPlugin.Plugin) + }), + ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), ).pipe( Layer.provideMerge(PluginV2.locationLayer), diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index f8d4ef03312..6f83a962cc3 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass()( } export interface RunOptions { + readonly combineOutput?: boolean readonly maxOutputBytes?: number readonly maxErrorBytes?: number readonly signal?: AbortSignal @@ -37,8 +38,10 @@ export interface RunStreamOptions { export interface RunResult { readonly command: string readonly exitCode: number + readonly output?: Buffer readonly stdout: Buffer readonly stderr: Buffer + readonly outputTruncated?: boolean readonly stdoutTruncated: boolean readonly stderrTruncated: boolean } @@ -143,6 +146,22 @@ export const layer = Layer.effect( const collect = Effect.scoped( Effect.gen(function* () { const handle = yield* spawner.spawn(command) + if (options?.combineOutput) { + const [output, exitCode] = yield* Effect.all( + [collectStream(handle.all, options.maxOutputBytes), handle.exitCode], + { concurrency: "unbounded" }, + ) + return { + command: description, + exitCode, + output: output.buffer, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + outputTruncated: output.truncated, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies RunResult + } const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStream(handle.stdout, options?.maxOutputBytes), diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index f86cbdd69e5..19507310c24 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -30,16 +30,15 @@ export const Input = Schema.Struct({ }), }) -const Output = Schema.Struct({ - command: Schema.String, - cwd: Schema.String, - exitCode: Schema.Number.pipe(Schema.optional), - /** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */ - output: Schema.String, +const StructuredOutput = Schema.Struct({ + exit: Schema.Number.pipe(Schema.optional), truncated: Schema.Boolean, - stdoutTruncated: Schema.Boolean.pipe(Schema.optional), - stderrTruncated: Schema.Boolean.pipe(Schema.optional), - timedOut: Schema.Boolean.pipe(Schema.optional), + timeout: Schema.Boolean.pipe(Schema.optional), +}) + +const Output = Schema.Struct({ + ...StructuredOutput.fields, + output: Schema.String, warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) @@ -47,24 +46,12 @@ type Output = typeof Output.Type const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") -const compactOutput = (stdout: string, stderr: string) => { - const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout - return output || "(no output)" -} - -const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => { - if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]" - if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]" - if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]" - return undefined -} - const modelOutput = (output: Output) => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" - if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.` - return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.` + if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` + return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } const isTimeout = (error: AppProcess.AppProcessError) => @@ -116,7 +103,16 @@ export const layer = Layer.effectDiscard( description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }], + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + truncated: output.truncated, + ...(output.exit === undefined ? {} : { exit: output.exit }), + ...(output.timeout === undefined ? {} : { timeout: output.timeout }), + }), + toModelOutput: ({ output }) => [ + { type: "text", text: output.output }, + { type: "text", text: modelOutput(output) }, + ], execute: (input, context) => Effect.gen(function* () { const source = { @@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard( const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const result = yield* appProcess .run(command, { + combineOutput: true, timeout: Duration.millis(timeout), maxOutputBytes: MAX_CAPTURE_BYTES, - maxErrorBytes: MAX_CAPTURE_BYTES, }) .pipe( Effect.catchTag("AppProcessError", (error) => @@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard( ) if (!result) { return { - command: input.command, - cwd: target.canonical, output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, - timedOut: true, + timeout: true, ...(warnings.length ? { warnings } : {}), } } - const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) - const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) + const output = result.output?.toString("utf8") || "(no output)" + const notice = result.outputTruncated + ? "[output capture truncated at the in-memory safety limit]" + : undefined return { - command: input.command, - cwd: target.canonical, - exitCode: result.exitCode, - output: notice ? `${compact}\n\n${notice}` : compact, - truncated: result.stdoutTruncated || result.stderrTruncated, + exit: result.exitCode, + output: notice ? `${output}\n\n${notice}` : output, + truncated: result.outputTruncated === true, ...(warnings.length ? { warnings } : {}), - ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), - ...(result.stderrTruncated ? { stderrTruncated: true } : {}), } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index eb70f2cb474..1d9a82e9522 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -37,10 +37,19 @@ export type Content = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } -type Config, Output extends SchemaType> = { +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { readonly description: string readonly input: Input readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type readonly execute: ( input: Schema.Schema.Type, context: Context, @@ -59,10 +68,12 @@ type Runtime = { const runtimes = new WeakMap() -export function make, Output extends SchemaType>( - config: Config, -): Definition { - const tool = Object.freeze({}) as Definition +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { definition: (name) => { @@ -72,7 +83,7 @@ export function make, Output extends SchemaType, Output extends SchemaType Schema.encodeEffect(config.output)(output).pipe( + Effect.flatMap((output) => { + if (!config.structured || !config.toStructuredOutput) + return Effect.succeed({ output, structured: output }) + return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( + Effect.map((structured) => ({ output, structured })), + ) + }), Effect.mapError( (error) => new ToolFailure({ @@ -92,8 +110,8 @@ export function make, Output extends SchemaType ({ - structured: output, + Effect.map(({ output, structured }) => ({ + structured, content: config.toModelOutput?.({ input, output }).map((part) => part.type === "text" diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index 580ff0fbf7e..fe169361178 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -39,6 +39,22 @@ describe("AppProcess", () => { }), ) + it.effect( + "captures stdout and stderr in emission order", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const script = [ + 'process.stdout.write("out 1\\n")', + 'setTimeout(() => process.stderr.write("err 1\\n"), 10)', + 'setTimeout(() => process.stdout.write("out 2\\n"), 20)', + ].join(";") + const result = yield* svc.run(cmd("-e", script), { combineOutput: true }) + expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n") + expect(result.stdout.toString("utf8")).toBe("") + expect(result.stderr.toString("utf8")).toBe("") + }), + ) + it.effect( "non-zero exit returns RunResult; caller can require success", Effect.gen(function* () { diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 9bbea5f0c3a..f15621ef582 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -31,8 +31,10 @@ let denyAction: string | undefined let result: AppProcess.RunResult = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -83,8 +85,10 @@ const reset = () => { result = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -135,24 +139,33 @@ describe("BashTool", () => { expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd") expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ - result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, + result: { + type: "content", + value: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], + }, output: { structured: { - command: "pwd", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "hello\n", + exit: 0, truncated: false, }, - content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }], + content: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], }, }) expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }]) expect(runs[0]?.options).toMatchObject({ + combineOutput: true, maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, - maxErrorBytes: BashTool.MAX_CAPTURE_BYTES, }) expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) }), @@ -222,13 +235,17 @@ describe("BashTool", () => { ).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." }) - expect(settled.output?.structured).toMatchObject({ - command: "printf core-bash", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "core-bash", + expect(settled.result).toEqual({ + type: "content", + value: [ + { type: "text", text: "core-bash" }, + { type: "text", text: "Command exited with code 0." }, + ], }) + expect(settled.output?.structured).toMatchObject({ + exit: 0, + }) + expect(settled.output?.structured).not.toHaveProperty("output") }), ), ) @@ -303,11 +320,13 @@ describe("BashTool", () => { expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(runs).toHaveLength(1) expect(settled.output?.structured).toMatchObject({ - warnings: [ - `Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ], + truncated: false, + }) + expect(settled.output?.structured).not.toHaveProperty("warnings") + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Warnings:"), }) - expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") }) }), ), ) @@ -324,21 +343,19 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } + result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command exited with code 7"), + text: expect.stringContaining("Command exited with code 7"), }) expect(settled.output?.structured).toMatchObject({ - command: "false", - cwd: realpathSync(tmp.path), - exitCode: 7, - output: "HEAD full output TAIL", + exit: 7, truncated: false, }) + expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" }) }), ), ) @@ -352,14 +369,14 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, stdoutTruncated: true } + result = { ...result, outputTruncated: true } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true }) - expect(settled.result).toMatchObject({ + expect(settled.output?.structured).toMatchObject({ truncated: true }) + expect(settled.output?.content[0]).toMatchObject({ type: "text", - value: expect.stringContaining("stdout capture truncated"), + text: expect.stringContaining("output capture truncated"), }) expect(settled.output?.structured).not.toHaveProperty("resource") }), @@ -379,13 +396,12 @@ describe("BashTool", () => { return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command timed out"), + text: expect.stringContaining("Command timed out"), }) expect(settled.output?.structured).toMatchObject({ - command: "sleep 60", - timedOut: true, + timeout: true, truncated: false, }) }), diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 67812169475..f218f8ec3f0 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -1,7 +1,7 @@ import type { AgentPart, OpencodeClient, - Event, + V2Event, FilePart, LspStatus, McpStatus, @@ -517,7 +517,10 @@ export type TuiSlots = { } export type TuiEventBus = { - on: (type: Type, handler: (event: Extract) => void) => () => void + on: ( + type: Type, + handler: (event: Extract) => void, + ) => () => void } export type TuiDispose = () => void | Promise diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 4af05c2584a..2c60124304e 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -969,7 +969,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
Promise args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -289,7 +290,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -360,6 +361,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() + const reload = sdk.reload const toast = useToast() const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState @@ -744,6 +746,33 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, + ...(reload + ? [ + { + name: "server.reload", + title: "Reload server", + slashName: "reload", + slashAliases: ["restart"], + run: async () => { + dialog.clear() + toast.show({ + variant: "info", + message: "Reloading server...", + duration: 30000, + }) + await reload() + .then(() => + toast.show({ + variant: "success", + message: "Server reloaded", + }), + ) + .catch(toast.error) + }, + category: "System", + }, + ] + : []), { name: "theme.switch", title: "Switch theme", @@ -940,16 +969,16 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi event.on("tui.command.execute", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return - keymap.dispatchCommand(evt.properties.command) + keymap.dispatchCommand(evt.data.command) }) event.on("tui.toast.show", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return toast.show({ - title: evt.properties.title, - message: evt.properties.message, - variant: evt.properties.variant, - duration: evt.properties.duration, + title: evt.data.title, + message: evt.data.message, + variant: evt.data.variant, + duration: evt.data.duration, }) }) @@ -957,12 +986,12 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi if (workspace !== project.workspace.current()) return route.navigate({ type: "session", - sessionID: evt.properties.sessionID, + sessionID: evt.data.sessionID, }) }) event.on("session.deleted", (evt) => { - if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { + if (route.data.type === "session" && route.data.sessionID === evt.data.info.id) { route.navigate({ type: "home" }) toast.show({ variant: "info", @@ -973,7 +1002,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi event.on("session.error", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return - const error = evt.properties.error + const error = evt.data.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return const message = errorMessage(error) @@ -986,7 +1015,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi event.on("installation.update-available", async (evt) => { console.log("installation.update-available", evt) - const version = evt.properties.version + const version = evt.data.version const skipped = kv.get("skipped_version") if (skipped && !isVersionGreater(version, skipped)) return @@ -1085,8 +1114,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi - - + + ) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 19a7396afa2..5f26a1bddd8 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -158,6 +158,12 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => data.session.status(props.sessionID ?? "")) + const activeSubagents = createMemo(() => + data.session + .list() + .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running") + .length, + ) const history = usePromptHistory() const stash = usePromptStash() const keymap = useOpencodeKeymap() @@ -235,7 +241,7 @@ export function Prompt(props: PromptProps) { event.on("tui.prompt.append", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return if (!input || input.isDestroyed) return - input.insertText(evt.properties.text) + input.insertText(evt.data.text) setTimeout(() => { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return @@ -1534,6 +1540,13 @@ export function Prompt(props: PromptProps) { + + {(count) => ( + + {count()} active subagent{count() === 1 ? "" : "s"} + + )} + 0 ? theme.primary : theme.text}> esc{" "} 0 ? theme.primary : theme.textMuted }}> diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 0f3c7dc09c6..9e92eb7a89e 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -21,15 +21,10 @@ import type { import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -import { createSignal, onCleanup, onMount } from "solid-js" -import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { createSignal, onCleanup } from "solid-js" -export type DataConnectionStatus = "connecting" | "connected" | "reconnecting" export type DataSessionStatus = "idle" | "running" -export type DataEvent = V2Event -type DataEventMap = { [T in DataEvent["type"]]: Extract } - type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -41,11 +36,6 @@ type LocationData = { } type Data = { - connection: { - status: DataConnectionStatus - attempt: number - error?: string - } session: { info: Record status: Record @@ -71,10 +61,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { const [store, setStore] = createStore({ - connection: { - status: "connecting", - attempt: 0, - }, session: { info: {}, status: {}, @@ -89,7 +75,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) const sdk = useSDK() - const events = createGlobalEmitter() const [defaultLocation, setDefaultLocation] = createSignal({ directory: process.cwd(), }) @@ -151,6 +136,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function handleEvent(event: V2Event) { switch (event.type) { + case "session.created": + void result.session.refresh(event.data.sessionID) + break case "catalog.updated": void Promise.all([ result.location.model.refresh(event.location), @@ -477,21 +465,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ]) break } - events.emit(event.type, event) } const result = { - on: events.on, - listen: events.listen, + on: sdk.event.on, + listen: sdk.event.listen, connection: { status() { - return store.connection.status + return sdk.connection.status() }, attempt() { - return store.connection.attempt + return sdk.connection.attempt() }, error() { - return store.connection.error + return sdk.connection.error() }, }, session: { @@ -687,52 +674,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ console.error("Failed to refresh default location data", failure.reason) } - onMount(() => { - const controller = new AbortController() - onCleanup(() => controller.abort()) - void (async () => { - while (!controller.signal.aborted) { - const error = await (async () => { - const events = await sdk.client.v2.event.subscribe({ - signal: controller.signal, - sseMaxRetryAttempts: 0, - throwOnError: true, - }) - const stream = events.stream[Symbol.asyncIterator]() - const first = await stream.next() - if (first.done) return new Error("Event stream disconnected") - setStore("connection", { status: "connected", attempt: 0, error: undefined }) - handleEvent(first.value) - await bootstrap() - while (!controller.signal.aborted) { - const event = await stream.next() - if (event.done) return new Error("Event stream disconnected") - handleEvent(event.value) - } - })().catch((error) => error) - if (controller.signal.aborted) return - setStore("connection", { - status: "reconnecting", - attempt: store.connection.status === "connected" ? 1 : store.connection.attempt + 1, - error: error instanceof Error ? error.message : String(error), - }) - await wait(250, controller.signal) - } - })() - }) + onCleanup( + sdk.event.listen(({ details }) => { + handleEvent(details) + if (details.type === "server.connected") void bootstrap() + }), + ) return result }, }) - -function wait(delay: number, signal: AbortSignal) { - return new Promise((resolve) => { - const timer = setTimeout(done, delay) - signal.addEventListener("abort", done, { once: true }) - function done() { - clearTimeout(timer) - signal.removeEventListener("abort", done) - resolve() - } - }) -} diff --git a/packages/tui/src/context/event.ts b/packages/tui/src/context/event.ts index 1060f3ab170..ed4685168c7 100644 --- a/packages/tui/src/context/event.ts +++ b/packages/tui/src/context/event.ts @@ -1,31 +1,27 @@ -import type { Event } from "@opencode-ai/sdk/v2" +import type { V2Event } from "@opencode-ai/sdk/v2" import { useSDK } from "./sdk" type EventMetadata = { - directory: string + directory: string | undefined workspace: string | undefined } export function useEvent() { const sdk = useSDK() - function subscribe(handler: (event: Event, metadata: EventMetadata) => void) { - return sdk.event.on("event", (event) => { - if (event.payload.type === "sync") { - return - } - - handler(event.payload, { directory: event.directory, workspace: event.workspace }) + function subscribe(handler: (event: V2Event, metadata: EventMetadata) => void) { + return sdk.event.listen(({ details }) => { + if (details.type === "server.connected") return + handler(details, { directory: details.location?.directory, workspace: details.location?.workspaceID }) }) } - function on( + function on( type: T, - handler: (event: Extract, metadata: EventMetadata) => void, + handler: (event: Extract, metadata: EventMetadata) => void, ) { - return subscribe((event: Event, metadata: EventMetadata) => { - if (event.type !== type) return - handler(event as Extract, metadata) + return sdk.event.on(type, (event) => { + handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID }) }) } diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 57ed0e54a81..389d66dc9f9 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -474,7 +474,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } event.on("session.deleted", (evt) => { - prune(evt.properties.info.id) + prune(evt.data.info.id) }) return { diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index 15a7fb27b40..a6130783064 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -1,4 +1,4 @@ -import { batch } from "solid-js" +import { batch, onCleanup } from "solid-js" import type { Path, Workspace } from "@opencode-ai/sdk/v2" import { createStore, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -67,11 +67,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex }) } - sdk.event.on("event", (event) => { - if (event.payload.type === "workspace.status") { - setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status) - } - }) + onCleanup( + sdk.event.on("workspace.status", (event) => { + setStore("workspace", "status", event.data.workspaceID, event.data.status) + }), + ) return { data: store, diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index e66930be3de..751f57bd13a 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,89 +1,145 @@ -import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2" -import { Flag } from "@opencode-ai/core/flag/flag" +import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -import { batch, onCleanup, onMount } from "solid-js" + +export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting" + +type SDKEventMap = { [Type in V2Event["type"]]: Extract } +const connectTimeout = 2_000 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", - init: (props: { client: OpencodeClient }) => { + init: (props: { client: OpencodeClient; reload?: () => Promise }) => { const abort = new AbortController() - const handlers = new Set<(event: GlobalEvent) => void>() - const emitter = { - emit(_type: "event", event: GlobalEvent) { - for (const handler of handlers) handler(event) - }, - on(_type: "event", handler: (event: GlobalEvent) => void) { - handlers.add(handler) - return () => { - handlers.delete(handler) - } - }, - } + let client = props.client + const events = createGlobalEmitter() + const [connection, setConnection] = createStore<{ + status: SDKConnectionStatus + attempt: number + error?: string + }>({ + status: "connecting", + attempt: 0, + }) + let stream: AbortController | undefined + let pending: Promise | undefined - let queue: GlobalEvent[] = [] - let timer: Timer | undefined - let last = 0 - const retryDelay = 1000 - const maxRetryDelay = 30000 - - const flush = () => { - if (queue.length === 0) return - const events = queue - queue = [] - timer = undefined - last = Date.now() - batch(() => { - for (const event of events) emitter.emit("event", event) + function start() { + stream?.abort() + const controller = new AbortController() + const current = client + let connected!: () => void + const ready = new Promise((resolve) => { + connected = resolve }) - } - - const handleEvent = (event: GlobalEvent) => { - queue.push(event) - const elapsed = Date.now() - last - if (timer) return - if (elapsed < 16) { - timer = setTimeout(flush, 16) - return - } - flush() - } - - onMount(() => { + stream = controller void (async () => { let attempt = 0 - while (!abort.signal.aborted) { - const events = await props.client.global.event({ - signal: abort.signal, - sseMaxRetryAttempts: 0, - }) - - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) await props.client.sync.start().catch(() => {}) - - for await (const event of events.stream) { - if (abort.signal.aborted) break - handleEvent(event) - } - - if (timer) clearTimeout(timer) - if (queue.length > 0) flush() + while (!abort.signal.aborted && !controller.signal.aborted) { + const connection = new AbortController() + const cancel = () => connection.abort(controller.signal.reason) + const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout) + controller.signal.addEventListener("abort", cancel, { once: true }) + const error = await (async () => { + const response = await current.v2.event.subscribe({ + signal: connection.signal, + sseMaxRetryAttempts: 0, + throwOnError: true, + }) + const iterator = response.stream[Symbol.asyncIterator]() + const first = await iterator.next() + if (abort.signal.aborted || controller.signal.aborted) return + if (first.done) + return connection.signal.reason instanceof Error + ? connection.signal.reason + : new Error("Event stream disconnected") + clearTimeout(timeout) + attempt = 0 + setConnection({ status: "connected", attempt: 0, error: undefined }) + events.emit(first.value.type, first.value) + connected() + while (!abort.signal.aborted && !controller.signal.aborted) { + const event = await iterator.next() + if (abort.signal.aborted || controller.signal.aborted) return + if (event.done) return new Error("Event stream disconnected") + events.emit(event.value.type, event.value) + } + })() + .catch((error) => error) + .finally(() => { + clearTimeout(timeout) + controller.signal.removeEventListener("abort", cancel) + }) + if (abort.signal.aborted || controller.signal.aborted) return attempt += 1 - if (abort.signal.aborted) break - await new Promise((resolve) => - setTimeout(resolve, Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)), - ) + setConnection({ + status: "reconnecting", + attempt, + error: error instanceof Error ? error.message : String(error), + }) + await wait(250, controller.signal) } - })().catch(() => {}) - }) + })() + return ready + } + const reload = props.reload + ? () => { + if (pending) return pending + pending = Promise.resolve() + .then(props.reload) + .then(async (next) => { + client = next + if (!abort.signal.aborted) await start() + }) + .finally(() => { + pending = undefined + }) + return pending + } + : undefined + + onMount(() => void start()) onCleanup(() => { abort.abort() - if (timer) clearTimeout(timer) - handlers.clear() + stream?.abort() + events.clear() }) return { - client: props.client, - event: emitter, + get client() { + return client + }, + event: { + on: events.on, + listen: events.listen, + }, + connection: { + status() { + return connection.status + }, + attempt() { + return connection.attempt + }, + error() { + return connection.error + }, + }, + reload, } }, }) + +function wait(delay: number, signal: AbortSignal) { + return new Promise((resolve) => { + const timer = setTimeout(done, delay) + signal.addEventListener("abort", done, { once: true }) + function done() { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + }) +} diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index 19ae505148d..81c696ef134 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -1,10 +1,10 @@ -import type { Event } from "@opencode-ai/sdk/v2" +import type { V2Event } from "@opencode-ai/sdk/v2" import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" const id = "internal:notifications" -type SessionError = Extract["properties"]["error"] +type SessionError = Extract["data"]["error"] function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) { const session = sessionID ? api.state.session.get(sessionID) : undefined @@ -33,27 +33,27 @@ const tui: TuiPlugin = async (api) => { const permissions = new Set() api.event.on("question.asked", (event) => { - if (questions.has(event.properties.id)) return - questions.add(event.properties.id) - notify(api, event.properties.sessionID, "Question needs input", "question") + if (questions.has(event.data.id)) return + questions.add(event.data.id) + notify(api, event.data.sessionID, "Question needs input", "question") }) api.event.on("question.replied", (event) => { - questions.delete(event.properties.requestID) + questions.delete(event.data.requestID) }) api.event.on("question.rejected", (event) => { - questions.delete(event.properties.requestID) + questions.delete(event.data.requestID) }) api.event.on("permission.asked", (event) => { - if (permissions.has(event.properties.id)) return - permissions.add(event.properties.id) - notify(api, event.properties.sessionID, "Permission needs input", "permission") + if (permissions.has(event.data.id)) return + permissions.add(event.data.id) + notify(api, event.data.sessionID, "Permission needs input", "permission") }) api.event.on("permission.replied", (event) => { - permissions.delete(event.properties.requestID) + permissions.delete(event.data.requestID) }) const started = (sessionID: string) => { @@ -74,18 +74,18 @@ const tui: TuiPlugin = async (api) => { notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } - api.event.on("session.next.prompted", (event) => started(event.properties.sessionID)) - api.event.on("session.next.shell.started", (event) => started(event.properties.sessionID)) - api.event.on("session.next.step.started", (event) => started(event.properties.sessionID)) - api.event.on("session.next.retried", (event) => started(event.properties.sessionID)) - api.event.on("session.next.compaction.started", (event) => started(event.properties.sessionID)) - api.event.on("session.next.shell.ended", (event) => ended(event.properties.sessionID)) + api.event.on("session.next.prompted", (event) => started(event.data.sessionID)) + api.event.on("session.next.shell.started", (event) => started(event.data.sessionID)) + api.event.on("session.next.step.started", (event) => started(event.data.sessionID)) + api.event.on("session.next.retried", (event) => started(event.data.sessionID)) + api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID)) + api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID)) api.event.on("session.next.step.ended", (event) => { - if (event.properties.finish === "tool-calls") return - ended(event.properties.sessionID) + if (event.data.finish === "tool-calls") return + ended(event.data.sessionID) }) api.event.on("session.next.step.failed", (event) => { - const sessionID = event.properties.sessionID + const sessionID = event.data.sessionID if (!active.has(sessionID)) return errored.add(sessionID) notify(api, sessionID, "Session error", "error") @@ -93,11 +93,11 @@ const tui: TuiPlugin = async (api) => { }) api.event.on("session.error", (event) => { - const sessionID = event.properties.sessionID + const sessionID = event.data.sessionID if (!sessionID) return if (!active.has(sessionID)) return errored.add(sessionID) - notify(api, sessionID, sessionErrorMessage(event.properties.error), "error") + notify(api, sessionID, sessionErrorMessage(event.data.error), "error") }) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 27db53be820..593faab777e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1091,32 +1091,15 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, ) return ( - <> - - - {errorMessage(props.message.error)} - - - - - - {Locale.titlecase(props.message.agent)} - - · {model()} - - · {Locale.duration(duration())} - - - - + + + {Locale.titlecase(props.message.agent)} + · {model()} + + · {Locale.duration(duration())} + + + ) } @@ -1868,13 +1851,19 @@ function Shell(props: ToolProps) { const color = createMemo(() => (permission() ? theme.warning : theme.text)) const isRunning = createMemo(() => props.part.state.status === "running") const command = createMemo(() => stringValue(props.input.command)) - const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? "")) + const output = createMemo(() => { + if (props.part.state.status === "pending") return "" + const content = props.part.state.content[0] + return stripAnsi(content?.type === "text" ? content.text.trim() : "") + }) const [expanded, setExpanded] = createSignal(false) const maxLines = 10 const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) - const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) + const input = createMemo(() => (command() ? `$ ${command()}` : "")) + const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n")) + const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars())) const limited = createMemo(() => { - if (expanded() || !collapsed().overflow) return output() + if (expanded() || !collapsed().overflow) return content() return collapsed().output }) @@ -1882,13 +1871,21 @@ function Shell(props: ToolProps) { setExpanded((prev) => !prev) : undefined}> Writing command...}> - $ {command()}}> - {command()} + + {limited().slice(0, input().length)} + {limited().slice(input().length)} + + } + > + + {limited().slice(0, input().length)} + {limited().slice(input().length)} + - - {limited()} - {expanded() ? "Click to collapse" : "Click to expand"} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index d4ac60ac3ef..e10a84f9d70 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -145,6 +145,7 @@ export function createSessionRows(sessionID: Accessor) { export function reduceSessionRows(messages: SessionMessage[]) { return messages.reduce((rows, message) => { if (message.type !== "assistant") { + completePrevious(rows) rows.push({ type: "message", messageID: message.id }) return rows } @@ -152,8 +153,10 @@ export function reduceSessionRows(messages: SessionMessage[]) { if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return append(rows, { messageID: message.id, partID: part.id }, part) }) - if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) + if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) { + completePrevious(rows) rows.push({ type: "assistant-footer", messageID: message.id }) + } return rows }, []) } diff --git a/packages/tui/src/util/collapse-tool-output.ts b/packages/tui/src/util/collapse-tool-output.ts index 4b4c8d87b9e..7e2a534cd50 100644 --- a/packages/tui/src/util/collapse-tool-output.ts +++ b/packages/tui/src/util/collapse-tool-output.ts @@ -4,7 +4,9 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n return { output, overflow: false } } - const preview = lines.slice(0, maxLines).join("\n") + const visible = lines.slice(0, maxLines) + if (lines.length > maxLines && visible.length > 0) visible[visible.length - 1] += "…" + const preview = visible.join("\n") if (Array.from(preview).length > maxChars) { return { output: @@ -15,5 +17,5 @@ export function collapseToolOutput(output: string, maxLines: number, maxChars: n } } - return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true } + return { output: preview, overflow: true } } diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 5c319850359..1a19f7be259 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" import Notifications from "../../../../src/feature-plugins/system/notifications" -import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2" +import type { PermissionRequest, QuestionRequest, Session, V2Event } from "@opencode-ai/sdk/v2" import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui" import { createTuiPluginApi } from "../../../fixture/tui-plugin" async function setup() { const notifications: TuiAttentionNotifyInput[] = [] - const handlers = new Map void)[]>() + const handlers = new Map void)[]>() const session = (id: string, title: string, parentID?: string): Session => ({ id, title, @@ -33,9 +33,12 @@ async function setup() { }, }, event: { - on: (type: Type, handler: (event: Extract) => void) => { + on: ( + type: Type, + handler: (event: Extract) => void, + ) => { const list = handlers.get(type) ?? [] - const wrapped = handler as (event: Event) => void + const wrapped = handler as (event: V2Event) => void list.push(wrapped) handlers.set(type, list) return () => { @@ -58,7 +61,7 @@ async function setup() { return { notifications, - emit(event: Event) { + emit(event: V2Event) { for (const handler of handlers.get(event.type) ?? []) handler(event) }, } @@ -83,11 +86,11 @@ function permission(id: string, sessionID = "session"): PermissionRequest { } } -function stepStarted(id: string, sessionID = "session"): Event { +function stepStarted(id: string, sessionID = "session"): V2Event { return { id, type: "session.next.step.started", - properties: { + data: { sessionID, assistantMessageID: `msg_${id}`, timestamp: 0, @@ -97,11 +100,11 @@ function stepStarted(id: string, sessionID = "session"): Event { } } -function stepEnded(id: string, sessionID = "session", finish = "stop"): Event { +function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event { return { id, type: "session.next.step.ended", - properties: { + data: { sessionID, assistantMessageID: `msg_${id}`, timestamp: 0, @@ -112,11 +115,11 @@ function stepEnded(id: string, sessionID = "session", finish = "stop"): Event { } } -function stepFailed(id: string, sessionID = "session"): Event { +function stepFailed(id: string, sessionID = "session"): V2Event { return { id, type: "session.next.step.failed", - properties: { + data: { sessionID, assistantMessageID: `msg_${id}`, timestamp: 0, @@ -143,8 +146,8 @@ describe("internal notifications TUI plugin", () => { test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => { const harness = await setup() - harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") }) - harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") }) + harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") }) + harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") }) expect(harness.notifications).toEqual([questionNotification, permissionNotification]) }) @@ -152,23 +155,23 @@ describe("internal notifications TUI plugin", () => { test("dedupes pending questions and permissions until they are resolved", async () => { const harness = await setup() - harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") }) - harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") }) + harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") }) + harness.emit({ id: "event-2", type: "question.asked", data: question("question-1") }) harness.emit({ id: "event-3", type: "question.replied", - properties: { sessionID: "session", requestID: "question-1", answers: [] }, + data: { sessionID: "session", requestID: "question-1", answers: [] }, }) - harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") }) + harness.emit({ id: "event-4", type: "question.asked", data: question("question-1") }) - harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") }) - harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") }) + harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") }) + harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") }) harness.emit({ id: "event-7", type: "permission.replied", - properties: { sessionID: "session", requestID: "permission-1", reply: "once" }, + data: { sessionID: "session", requestID: "permission-1", reply: "once" }, }) - harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") }) + harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") }) expect(harness.notifications).toEqual([ questionNotification, @@ -198,7 +201,7 @@ describe("internal notifications TUI plugin", () => { test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => { const harness = await setup() - harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") }) + harness.emit({ id: "event-1", type: "question.asked", data: question("question-1", "subagent") }) harness.emit(stepStarted("event-2", "subagent")) harness.emit(stepEnded("event-3", "subagent")) @@ -242,13 +245,13 @@ describe("internal notifications TUI plugin", () => { harness.emit({ id: "event-2", type: "session.error", - properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, + data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, }) harness.emit(stepStarted("event-3", "timeout")) harness.emit({ id: "event-4", type: "session.error", - properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, + data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, }) expect(harness.notifications).toEqual([ diff --git a/packages/tui/test/cli/tui/collapse-tool-output.test.ts b/packages/tui/test/cli/tui/collapse-tool-output.test.ts new file mode 100644 index 00000000000..f12697d5c85 --- /dev/null +++ b/packages/tui/test/cli/tui/collapse-tool-output.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { collapseToolOutput } from "../../../src/util/collapse-tool-output" + +test("limits command input and output to the same line budget", () => { + const command = Array.from({ length: 8 }, (_, index) => `command ${index + 1}`).join("\n") + const output = Array.from({ length: 4 }, (_, index) => `output ${index + 1}`).join("\n") + const collapsed = collapseToolOutput(`$ ${command}\n\n${output}`, 10, 1_000) + + expect(collapsed.overflow).toBe(true) + expect(collapsed.output.split("\n")).toHaveLength(10) + expect(collapsed.output).toContain("$ command 1") + expect(collapsed.output).toContain("command 8\n\noutput 1…") + expect(collapsed.output).not.toContain("output 2") +}) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index e37071269d4..29d0e79956f 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2" +import type { V2Event } from "@opencode-ai/sdk/v2" import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" @@ -17,12 +17,8 @@ async function wait(fn: () => boolean, timeout = 2000) { } } -function global(payload: Event): GlobalEvent { - return { directory, project: "proj_test", payload } -} - -function emitEvent(events: ReturnType, payload: Event) { - events.emit(global(payload)) +function emitEvent(events: ReturnType, event: V2Event) { + events.emit({ ...event, location: { directory } }) } test("refreshes resources into reactive getters", async () => { @@ -205,7 +201,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_started", type: "session.next.step.started", - properties: { + data: { sessionID: "session-live", assistantMessageID: "message-live", timestamp: 1, @@ -218,7 +214,7 @@ test("tracks session status from active sessions and execution events", async () emitEvent(events, { id: "evt_step_ended", type: "session.next.step.ended", - properties: { + data: { sessionID: "session-live", assistantMessageID: "message-live", timestamp: 2, @@ -291,7 +287,7 @@ test("refreshes integrations after integration updates", async () => { expect(data.location.integration.list()).toEqual([]) const before = { ...requests } - emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} }) + emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} }) await wait(() => data.location.integration.list()?.length === 1) await wait(() => requests.model > before.model && requests.provider > before.provider) expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" }) @@ -329,7 +325,7 @@ test("refreshes effective catalog data after catalog updates", async () => { try { await wait(() => requests.model > 0 && requests.provider > 0) const before = { ...requests } - emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} }) + emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} }) await wait(() => requests.model > before.model && requests.provider > before.provider) } finally { app.renderer.destroy() @@ -374,7 +370,7 @@ test("refreshes references after updates", async () => { try { await mounted await wait(() => requests === 1) - emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} }) + emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} }) await wait(() => data.location.reference.list()?.length === 1) expect(data.location.reference.list()?.[0]?.name).toBe("docs") } finally { @@ -409,7 +405,7 @@ test("adds and dismisses permission requests from live events", async () => { emitEvent(events, { id: "evt_permission_asked_1", type: "permission.v2.asked", - properties: { + data: { id: "per_1", sessionID: "ses_1", action: "bash", @@ -419,7 +415,7 @@ test("adds and dismisses permission requests from live events", async () => { emitEvent(events, { id: "evt_permission_asked_2", type: "permission.v2.asked", - properties: { + data: { id: "per_2", sessionID: "ses_1", action: "read", @@ -431,7 +427,7 @@ test("adds and dismisses permission requests from live events", async () => { emitEvent(events, { id: "evt_permission_replied_1", type: "permission.v2.replied", - properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" }, + data: { sessionID: "ses_1", requestID: "per_1", reply: "once" }, }) await wait(() => data.session.permission.list("ses_1")?.length === 1) expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2") @@ -439,7 +435,7 @@ test("adds and dismisses permission requests from live events", async () => { emitEvent(events, { id: "evt_permission_replied_2", type: "permission.v2.replied", - properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" }, + data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" }, }) await wait(() => data.session.permission.list("ses_1")?.length === 0) } finally { @@ -474,7 +470,7 @@ test("adds and dismisses question requests from live events", async () => { emitEvent(events, { id: "evt_question_asked_1", type: "question.v2.asked", - properties: { + data: { id: "que_1", sessionID: "ses_1", questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }], @@ -483,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => { emitEvent(events, { id: "evt_question_asked_2", type: "question.v2.asked", - properties: { + data: { id: "que_2", sessionID: "ses_1", questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }], @@ -494,7 +490,7 @@ test("adds and dismisses question requests from live events", async () => { emitEvent(events, { id: "evt_question_replied_1", type: "question.v2.replied", - properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] }, + data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] }, }) await wait(() => data.session.question.list("ses_1")?.length === 1) expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2") @@ -502,7 +498,7 @@ test("adds and dismisses question requests from live events", async () => { emitEvent(events, { id: "evt_question_rejected_2", type: "question.v2.rejected", - properties: { sessionID: "ses_1", requestID: "que_2" }, + data: { sessionID: "ses_1", requestID: "que_2" }, }) await wait(() => data.session.question.list("ses_1")?.length === 0) } finally { @@ -542,12 +538,12 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_agent_1", type: "session.next.agent.switched", - properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, }) emitEvent(events, { id: "evt_model_1", type: "session.next.model.switched", - properties: { + data: { sessionID: "session-1", messageID: "msg_model_1", timestamp: 0, @@ -557,7 +553,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_step_started_1", type: "session.next.step.started", - properties: { + data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", timestamp: 1, @@ -568,7 +564,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_input_1", type: "session.next.tool.input.started", - properties: { + data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", timestamp: 2, @@ -579,7 +575,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_called_1", type: "session.next.tool.called", - properties: { + data: { sessionID: "session-1", timestamp: 2, assistantMessageID: "msg_explicit_assistant_9", @@ -592,7 +588,7 @@ test("settles pending tools when a live failure arrives", async () => { emitEvent(events, { id: "evt_failed_1", type: "session.next.tool.failed", - properties: { + data: { sessionID: "session-1", timestamp: 3, assistantMessageID: "msg_explicit_assistant_9", @@ -673,7 +669,7 @@ test("renders admitted prompts only after they become model-visible", async () = emitEvent(events, { id: "evt_admitted_1", type: "session.next.prompt.admitted", - properties: { + data: { sessionID: "session-1", messageID: "msg_user_1", timestamp: 0, @@ -686,7 +682,7 @@ test("renders admitted prompts only after they become model-visible", async () = emitEvent(events, { id: "evt_prompted_1", type: "session.next.prompted", - properties: { + data: { sessionID: "session-1", messageID: "msg_user_1", timestamp: 0, @@ -744,7 +740,7 @@ test("projects live context updates with their message ID", async () => { emitEvent(events, { id: "evt_context_1", type: "session.next.context.updated", - properties: { + data: { sessionID: "session-1", messageID: "msg_context_1", timestamp: 1, diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index d2aa901f30f..d6a804625f1 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -89,6 +89,39 @@ test("groups across empty assistant reasoning parts", () => { ]) }) +test("completes exploration groups when another row follows", () => { + const finished = assistant("assistant-2", [ + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, + ]) + finished.finish = "stop" + const messages: SessionMessage[] = [ + assistant("assistant-1", [ + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, + ]), + { type: "user", id: "user-1", text: "Continue", time: { created: 2 } }, + finished, + ] + + expect(reduceSessionRows(messages)).toEqual([ + { + type: "group", + kind: "exploration", + pending: [], + completed: true, + refs: [{ messageID: "assistant-1", partID: "read-1" }], + }, + { type: "message", messageID: "user-1" }, + { + type: "group", + kind: "exploration", + pending: [], + completed: true, + refs: [{ messageID: "assistant-2", partID: "grep-1" }], + }, + { type: "assistant-footer", messageID: "assistant-2" }, + ]) +}) + function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { return { type: "assistant", diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 5f0438456db..108bf940c98 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -1,10 +1,10 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2" +import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" import { onMount } from "solid-js" import { ProjectProvider, useProject } from "../../../src/context/project" -import { SDKProvider } from "../../../src/context/sdk" +import { SDKProvider, useSDK } from "../../../src/context/sdk" import { useEvent } from "../../../src/context/event" import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -19,41 +19,40 @@ async function wait(fn: () => boolean, timeout = 2000) { } } -function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent { +function event(payload: V2Event, input: { directory: string; project?: string; workspace?: string }): V2Event { return { - directory: input.directory, - project: input.project, - workspace: input.workspace, - payload, + ...payload, + location: { directory: input.directory, workspaceID: input.workspace }, } } -function vcs(branch: string): Event { +function vcs(branch: string): V2Event { return { id: `evt_vcs_${branch}`, type: "vcs.branch.updated", - properties: { + data: { branch, }, } } -function update(version: string): Event { +function update(version: string): V2Event { return { id: `evt_update_${version}`, type: "installation.update-available", - properties: { + data: { version, }, } } -async function mount() { +async function mount(reload?: () => Promise) { const events = createEventStream() const calls = createFetch(undefined, events) - const seen: Event[] = [] + const seen: V2Event[] = [] const workspaces: Array = [] let project!: ReturnType + let sdk!: ReturnType let done!: () => void const ready = new Promise((resolve) => { done = resolve @@ -61,11 +60,12 @@ async function mount() { const app = await testRender(() => ( - + { project = ctx.project + sdk = ctx.sdk await project.sync() done() }} @@ -78,15 +78,16 @@ async function mount() { )) await ready - return { app, emit: events.emit, project, seen, workspaces } + return { app, emit: events.emit, project, sdk, seen, workspaces } } function Probe(props: { - seen: Event[] + seen: V2Event[] workspaces: Array - onReady: (ctx: { project: ReturnType }) => void + onReady: (ctx: { project: ReturnType; sdk: ReturnType }) => void }) { const project = useProject() + const sdk = useSDK() const event = useEvent() onMount(() => { @@ -94,7 +95,7 @@ function Probe(props: { props.seen.push(evt) props.workspaces.push(workspace) }) - props.onReady({ project }) + props.onReady({ project, sdk }) }) return @@ -109,7 +110,7 @@ describe("useEvent", () => { await wait(() => seen.length === 1) - expect(seen).toEqual([vcs("main")]) + expect(seen).toEqual([event(vcs("main"), { directory: "/tmp/other", workspace: "ws_a" })]) expect(workspaces).toEqual(["ws_a"]) } finally { app.renderer.destroy() @@ -125,7 +126,7 @@ describe("useEvent", () => { await wait(() => seen.length === 1) - expect(seen).toEqual([vcs("ws")]) + expect(seen).toEqual([event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_b" })]) } finally { app.renderer.destroy() } @@ -140,7 +141,54 @@ describe("useEvent", () => { await wait(() => seen.length === 1) - expect(seen).toEqual([update("1.2.3")]) + expect(seen).toEqual([event(update("1.2.3"), { directory: "global" })]) + } finally { + app.renderer.destroy() + } + }) + + test("reloads the host and reconnects the event stream", async () => { + let calls = 0 + const events = createEventStream() + const replacement = createClient(createFetch(undefined, events).fetch) + const { app, sdk, seen } = await mount(async () => { + calls += 1 + return replacement + }) + + try { + await wait(() => sdk.connection.status() === "connected") + await sdk.reload?.() + await wait(() => sdk.connection.status() === "connected") + events.emit(event(vcs("reloaded"), { directory: "/tmp/reloaded" })) + await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded")) + + expect(calls).toBe(1) + expect(sdk.client).toBe(replacement) + } finally { + app.renderer.destroy() + } + }) + + test("keeps the current event stream alive while the host reload is pending", async () => { + let complete!: (client: OpencodeClient) => void + const pending = new Promise((resolve) => { + complete = resolve + }) + const replacementEvents = createEventStream() + const replacement = createClient(createFetch(undefined, replacementEvents).fetch) + const { app, emit, sdk, seen } = await mount(() => pending) + + try { + await wait(() => sdk.connection.status() === "connected") + const reload = sdk.reload?.() + emit(event(vcs("during-reload"), { directory: "/tmp/reload" })) + await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "during-reload")) + + expect(sdk.connection.status()).toBe("connected") + complete(replacement) + await reload + expect(sdk.client).toBe(replacement) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index dbe4b7a5b10..d7f6cfb1a5a 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -1,5 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" -import type { GlobalEvent } from "@opencode-ai/sdk/v2" +import type { V2Event } from "@opencode-ai/sdk/v2" export const worktree = "/tmp/opencode" export const directory = `${worktree}/packages/tui` @@ -13,12 +13,8 @@ export function json(data: unknown, init?: ResponseInit) { export function createEventStream() { const encoder = new TextEncoder() - const global = new Set>() const v2 = new Set>() - const pending = { - global: [] as Uint8Array[], - v2: [] as Uint8Array[], - } + const pending: Uint8Array[] = [] const response = ( controllers: Set>, queued: Uint8Array[], @@ -54,24 +50,14 @@ export function createEventStream() { } return { - emit(event: GlobalEvent) { - send(global, pending.global, event) - if (!("properties" in event.payload)) return - send(v2, pending.v2, { - ...event.payload, - location: { directory: event.directory, workspaceID: event.workspace }, - data: event.payload.properties, - }) - }, - global() { - return response(global, pending.global) + emit(event: V2Event) { + send(v2, pending, event) }, v2() { - return response(v2, pending.v2, { id: "evt_connected", type: "server.connected", data: {} }) + return response(v2, pending, { id: "evt_connected", type: "server.connected", data: {} }) }, disconnect() { - for (const controller of [...global, ...v2]) controller.close() - global.clear() + for (const controller of v2) controller.close() v2.clear() }, } @@ -86,7 +72,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType