fix: stabilize v2 runtime behavior

This commit is contained in:
Dax Raad 2026-06-26 20:56:36 -04:00
parent ac2a78391f
commit 655adbf46e
35 changed files with 745 additions and 428 deletions

View file

@ -4,6 +4,7 @@ on:
push:
branches:
- dev
- v2
pull_request:
workflow_dispatch:

View file

@ -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",

View file

@ -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())
},
)
}),
)

View file

@ -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<void>((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<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> =>

View file

@ -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))

View file

@ -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),
)

View file

@ -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<Transport>) {
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: {

View file

@ -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
}),
),
)

View file

@ -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<Uint8Array>) {
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<boolean> {
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
}
}

View file

@ -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),

View file

@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
}
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),

View file

@ -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}` }))),
}),

View file

@ -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<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
type Config<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => Schema.Schema.Type<Structured>
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
@ -59,10 +68,12 @@ type Runtime = {
const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
@ -72,7 +83,7 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output),
outputSchema: toJsonSchema(config.structured ?? config.output),
})
definitions.set(name, definition)
return definition
@ -84,6 +95,13 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
config.execute(input, context).pipe(
Effect.flatMap((output) =>
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<Input extends SchemaType<any>, Output extends SchemaType<an
),
),
),
Effect.map((output) => ({
structured: output,
Effect.map(({ output, structured }) => ({
structured,
content:
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"

View file

@ -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* () {

View file

@ -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,
})
}),

View file

@ -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 extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
on: <Type extends V2Event["type"]>(
type: Type,
handler: (event: Extract<V2Event, { type: Type }>) => void,
) => () => void
}
export type TuiDispose = () => void | Promise<void>

View file

@ -969,7 +969,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
<div data-component="context-tool-group-trigger">
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
class="min-w-0 flex items-center gap-2 text-14-medium"
classList={{
"text-text-strong": pending(),
"text-text-weak": !pending(),
}}
>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
@ -981,7 +985,11 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS
</span>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal"
classList={{
"text-text-base": pending(),
"text-text-weak": !pending(),
}}
>
<AnimatedCountList
items={[

View file

@ -135,6 +135,7 @@ const appBindingCommands = [
export type TuiInput = {
client: OpencodeClient
reload?: () => Promise<OpencodeClient>
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
@ -289,7 +290,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider client={input.client}>
<SDKProvider client={input.client} reload={input.reload}>
<ProjectProvider>
<SyncProvider>
<DataProvider>
@ -360,6 +361,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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<string[]>; 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<string[]>; 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<string[]>; 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<string[]>; 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<string[]>; 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<string[]>; pluginHost: TuiPlugi
<Show when={!startup.skipInitialLoading}>
<StartupLoading ready={ready} />
</Show>
<Show when={data.connection.status() === "reconnecting"}>
<Reconnecting attempt={data.connection.attempt()} error={data.connection.error()} />
<Show when={sdk.connection.status() === "reconnecting"}>
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
</Show>
</box>
)

View file

@ -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) {
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<Show when={activeSubagents()}>
{(count) => (
<Spinner color={theme.textMuted}>
{count()} active subagent{count() === 1 ? "" : "s"}
</Spinner>
)}
</Show>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>

View file

@ -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<DataEvent, { type: T }> }
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
@ -41,11 +36,6 @@ type LocationData = {
}
type Data = {
connection: {
status: DataConnectionStatus
attempt: number
error?: string
}
session: {
info: Record<string, SessionV2Info>
status: Record<string, DataSessionStatus>
@ -71,10 +61,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: () => {
const [store, setStore] = createStore<Data>({
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<DataEventMap>()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
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<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}

View file

@ -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<T extends Event["type"]>(
function on<T extends V2Event["type"]>(
type: T,
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
handler: (event: Extract<V2Event, { type: T }>, metadata: EventMetadata) => void,
) {
return subscribe((event: Event, metadata: EventMetadata) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>, metadata)
return sdk.event.on(type, (event) => {
handler(event, { directory: event.location?.directory, workspace: event.location?.workspaceID })
})
}

View file

@ -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 {

View file

@ -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,

View file

@ -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<V2Event, { type: Type }> }
const connectTimeout = 2_000
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { client: OpencodeClient }) => {
init: (props: { client: OpencodeClient; reload?: () => Promise<OpencodeClient> }) => {
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<SDKEventMap>()
const [connection, setConnection] = createStore<{
status: SDKConnectionStatus
attempt: number
error?: string
}>({
status: "connecting",
attempt: 0,
})
let stream: AbortController | undefined
let pending: Promise<void> | 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<void>((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<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}

View file

@ -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<Event, { type: "session.error" }>["properties"]["error"]
type SessionError = Extract<V2Event, { type: "session.error" }>["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<string>()
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")
})
}

View file

@ -1091,32 +1091,15 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
)
return (
<>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.error}
>
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</>
<box paddingLeft={3}>
<text>
<span style={{ fg: local.agent.color(props.message.agent) }}>{Locale.titlecase(props.message.agent)}</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
)
}
@ -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) {
<BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}>
<box gap={1}>
<Show when={command()} fallback={<Spinner color={color()}>Writing command...</Spinner>}>
<Show when={isRunning()} fallback={<text fg={permission() ? theme.warning : theme.textMuted}>$ {command()}</text>}>
<Spinner color={color()}>{command()}</Spinner>
<Show
when={isRunning()}
fallback={
<text>
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
</text>
}
>
<Spinner color={color()}>
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
</Spinner>
</Show>
</Show>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>

View file

@ -145,6 +145,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
export function reduceSessionRows(messages: SessionMessage[]) {
return messages.reduce<SessionRow[]>((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
}, [])
}

View file

@ -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 }
}

View file

@ -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<Event["type"], ((event: Event) => void)[]>()
const handlers = new Map<V2Event["type"], ((event: V2Event) => void)[]>()
const session = (id: string, title: string, parentID?: string): Session => ({
id,
title,
@ -33,9 +33,12 @@ async function setup() {
},
},
event: {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
on: <Type extends V2Event["type"]>(
type: Type,
handler: (event: Extract<V2Event, { type: Type }>) => 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([

View file

@ -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")
})

View file

@ -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<typeof createEventStream>, payload: Event) {
events.emit(global(payload))
function emitEvent(events: ReturnType<typeof createEventStream>, 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,

View file

@ -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",

View file

@ -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<OpencodeClient>) {
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: Event[] = []
const seen: V2Event[] = []
const workspaces: Array<string | undefined> = []
let project!: ReturnType<typeof useProject>
let sdk!: ReturnType<typeof useSDK>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
@ -61,11 +60,12 @@ async function mount() {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}>
<SDKProvider client={createClient(calls.fetch)} reload={reload}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
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<string | undefined>
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
onReady: (ctx: { project: ReturnType<typeof useProject>; sdk: ReturnType<typeof useSDK> }) => 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 <box />
@ -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<OpencodeClient>((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()
}

View file

@ -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<ReadableStreamDefaultController<Uint8Array>>()
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
const pending = {
global: [] as Uint8Array[],
v2: [] as Uint8Array[],
}
const pending: Uint8Array[] = []
const response = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
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<typeof
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
if (url.pathname === "/global/event" && events) return events.global()
if (url.pathname === "/api/event" && events) return events.v2()
if (

View file

@ -12,6 +12,10 @@
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/tui#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],
"outputs": []