From fd01dc9c890057cd055a5ba1e5307597e0f04a4d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:31:21 -0400 Subject: [PATCH 01/19] test(httpapi): add route exerciser --- packages/opencode/script/httpapi-exercise.ts | 1709 +++++++++++++++++ .../src/server/routes/instance/tui.ts | 6 +- packages/opencode/src/storage/db.ts | 1 + packages/opencode/src/util/lazy.ts | 2 + packages/opencode/test/AGENTS.md | 33 +- packages/opencode/test/bus/bus-effect.test.ts | 167 +- packages/opencode/test/fixture/fixture.ts | 16 + packages/opencode/test/lib/effect.ts | 48 +- .../opencode/test/question/question.test.ts | 718 ++++--- packages/opencode/test/server/global-bus.ts | 34 + .../test/server/httpapi-config.test.ts | 20 +- .../test/server/httpapi-experimental.test.ts | 19 +- .../server/httpapi-instance-context.test.ts | 24 +- .../server/httpapi-instance.legacy.test.ts | 32 +- .../opencode/test/server/httpapi-tui.test.ts | 13 +- packages/opencode/test/tool/glob.test.ts | 78 +- packages/opencode/test/tool/grep.test.ts | 103 +- packages/opencode/test/tool/question.test.ts | 85 +- packages/opencode/test/tool/read.test.ts | 26 +- packages/opencode/test/tool/registry.test.ts | 248 ++- packages/opencode/test/tool/write.test.ts | 316 ++- 21 files changed, 2685 insertions(+), 1013 deletions(-) create mode 100644 packages/opencode/script/httpapi-exercise.ts create mode 100644 packages/opencode/test/server/global-bus.ts diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts new file mode 100644 index 0000000000..f0faa27602 --- /dev/null +++ b/packages/opencode/script/httpapi-exercise.ts @@ -0,0 +1,1709 @@ +/** + * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. + * + * The goal is not to be a normal unit test file. This is a route-coverage and parity + * harness we can run while deleting Hono: every public route should eventually have a + * small scenario that proves the Effect route decodes requests, uses the right instance + * context, mutates storage when expected, and returns a compatible response shape. + * + * The script intentionally isolates `OPENCODE_DB` before importing modules that touch + * storage. Scenarios may create/delete sessions and reset the database after each run, + * so this must never point at a developer's real session database. + * + * DSL shape: + * - `http.get/post/...` starts a scenario for one OpenAPI route key. + * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. + * - `.at(...)` builds the request from that typed state. + * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. + * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts + * so destructive routes compare equivalent fresh setups instead of sharing one DB. + */ +import { Cause, ConfigProvider, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { TestLLMServer } from "../test/lib/llm-server" +import type { Config } from "../src/config/config" +import { MessageID, PartID, type SessionID } from "../src/session/schema" +import { ModelID, ProviderID } from "../src/provider/schema" +import type { MessageV2 } from "../src/session/message-v2" +import type { Worktree } from "../src/worktree" +import type { Project } from "../src/project/project" +import path from "path" + +const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL +const exerciseGlobalRoot = process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") +process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") +process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") +process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") +process.env.OPENCODE_DISABLE_SHARE = "true" +const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") +const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") + +const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB +const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +process.env.OPENCODE_DB = exerciseDatabasePath +Flag.OPENCODE_DB = exerciseDatabasePath + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const +const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const +const color = { + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + reset: "\x1b[0m", +} + +type Method = (typeof Methods)[number] +type OpenApiMethod = (typeof OpenApiMethods)[number] +type Mode = "effect" | "parity" | "coverage" +type Backend = "effect" | "legacy" +type Comparison = "none" | "status" | "json" +type CaptureMode = "full" | "stream" +type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } +type OpenApiSpec = { paths?: Record>> } +type JsonObject = Record + +type Options = { + mode: Mode + include: string | undefined + failOnMissing: boolean + failOnSkip: boolean +} + +type RequestSpec = { + path: string + headers?: Record + body?: unknown +} + +type CallResult = { + status: number + contentType: string + body: unknown + text: string +} + +type BackendApp = { + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +/** Effect-native helpers available while setting up and asserting a scenario. */ +type ScenarioContext = { + directory: string | undefined + headers: (extra?: Record) => Record + file: (name: string, content: string) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + sessionGet: (sessionID: SessionID) => Effect.Effect + project: () => Effect.Effect + message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect + messages: (sessionID: SessionID) => Effect.Effect + todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect + worktree: (input?: { name?: string }) => Effect.Effect + worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect + tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect +} + +/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ +type SeededContext = ScenarioContext & { + state: S +} + +type Scenario = ActiveScenario | TodoScenario +type ActiveScenario = { + kind: "active" + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: ScenarioContext, state: unknown) => RequestSpec + expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect + compare: Comparison + capture: CaptureMode + mutates: boolean + reset: boolean +} + +/** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */ +type BuilderState = { + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: SeededContext) => RequestSpec + capture: CaptureMode + mutates: boolean + reset: boolean +} +type TodoScenario = { + kind: "todo" + method: Method + path: string + name: string + reason: string +} +type Result = + | { status: "pass"; scenario: ActiveScenario } + | { status: "fail"; scenario: ActiveScenario; message: string } + | { status: "skip"; scenario: TodoScenario } + +type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } +type TodoInfo = { content: string; status: string; priority: string } +type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +type Runtime = { + PublicApi: typeof import("../src/server/routes/instance/httpapi/public")["PublicApi"] + ExperimentalHttpApiServer: typeof import("../src/server/routes/instance/httpapi/server")["ExperimentalHttpApiServer"] + Server: typeof import("../src/server/server")["Server"] + AppLayer: typeof import("../src/effect/app-runtime")["AppLayer"] + InstanceRef: typeof import("../src/effect/instance-ref")["InstanceRef"] + Instance: typeof import("../src/project/instance")["Instance"] + InstanceStore: typeof import("../src/project/instance-store")["InstanceStore"] + Session: typeof import("../src/session/session")["Session"] + Todo: typeof import("../src/session/todo")["Todo"] + Worktree: typeof import("../src/worktree")["Worktree"] + Project: typeof import("../src/project/project")["Project"] + Tui: typeof import("../src/server/routes/instance/tui") + disposeAllInstances: typeof import("../test/fixture/fixture")["disposeAllInstances"] + tmpdir: typeof import("../test/fixture/fixture")["tmpdir"] + resetDatabase: typeof import("../test/fixture/db")["resetDatabase"] +} + +let runtimePromise: Promise | undefined + +function runtime() { + return (runtimePromise ??= (async () => { + const publicApi = await import("../src/server/routes/instance/httpapi/public") + const httpApiServer = await import("../src/server/routes/instance/httpapi/server") + const server = await import("../src/server/server") + const appRuntime = await import("../src/effect/app-runtime") + const instanceRef = await import("../src/effect/instance-ref") + const instance = await import("../src/project/instance") + const instanceStore = await import("../src/project/instance-store") + const session = await import("../src/session/session") + const todo = await import("../src/session/todo") + const worktree = await import("../src/worktree") + const project = await import("../src/project/project") + const tui = await import("../src/server/routes/instance/tui") + const fixture = await import("../test/fixture/fixture") + const db = await import("../test/fixture/db") + return { + PublicApi: publicApi.PublicApi, + ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + Server: server.Server, + AppLayer: appRuntime.AppLayer, + InstanceRef: instanceRef.InstanceRef, + Instance: instance.Instance, + InstanceStore: instanceStore.InstanceStore, + Session: session.Session, + Todo: todo.Todo, + Worktree: worktree.Worktree, + Project: project.Project, + Tui: tui, + disposeAllInstances: fixture.disposeAllInstances, + tmpdir: fixture.tmpdir, + resetDatabase: db.resetDatabase, + } + })()) +} + +class ScenarioBuilder { + private readonly state: BuilderState + + constructor(method: Method, path: string, name: string) { + this.state = { + method, + path, + name, + project: { git: true }, + seed: () => Effect.succeed(undefined as S), + request: (ctx) => ({ path, headers: ctx.headers() }), + capture: "full", + mutates: false, + reset: true, + } + } + + global() { + return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) + } + + inProject(project: ProjectOptions = { git: true }) { + return this.clone({ project }) + } + + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + + at(request: BuilderState["request"]) { + return this.clone({ request }) + } + + mutating() { + return this.clone({ mutates: true }) + } + + preserveDatabase() { + return this.clone({ reset: false }) + } + + stream() { + return this.clone({ capture: "stream" }) + } + + /** Assert a non-JSON or shape-only response. */ + ok(status = 200, compare: Comparison = "status") { + return this.done(compare, (_ctx, result) => + Effect.sync(() => { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + }), + ) + } + + status(status = 200, inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, compare: Comparison = "status") { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (inspect) yield* inspect(ctx, result) + }), + ) + } + + /** Assert JSON status/content-type plus an optional synchronous body check. */ + json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { + return this.jsonEffect( + status, + inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, + compare, + ) + } + + /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ + jsonEffect(status = 200, inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, compare: Comparison = "json") { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (!looksJson(result)) throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (inspect) yield* inspect(result.body, ctx) + }), + ) + } + + private clone(next: Partial>) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, next) + return builder + } + + /** + * Seed typed state before the HTTP request. The returned value becomes `ctx.state` + * for `.at(...)` and assertions, giving stateful route tests type-safe setup. + */ + seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, { seed }) + return builder + } + + private done(compare: Comparison, expect: (ctx: SeededContext, result: CallResult) => Effect.Effect): ActiveScenario { + const state = this.state + return { + kind: "active", + method: state.method, + path: state.path, + name: state.name, + project: state.project, + seed: state.seed, + request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), + compare, + capture: state.capture, + mutates: state.mutates, + reset: state.reset, + } + } +} + +const http = { + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), +} + +const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ + kind: "todo", + method, + path, + name, + reason, +}) + +function route(template: string, params: Record) { + return Object.entries(params).reduce((next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), template) +} + +const scenarios: Scenario[] = [ + http.get("/global/health", "global.health").global().json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), + http + .get("/global/event", "global.event") + .global() + .stream() + .status(200, (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status"), + http.get("/global/config", "global.config.get").global().json(), + http + .patch("/global/config", "global.config.update") + .global() + .seeded(() => + Effect.promise(() => + Bun.write(path.join(exerciseConfigDirectory, "opencode.jsonc"), JSON.stringify({ username: "httpapi-global" }, null, 2)), + ), + ) + .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text()) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status"), + http.post("/global/dispose", "global.dispose").global().mutating().json(200, (body) => { + check(body === true, "global dispose should return true") + }, "status"), + http.get("/path", "path.get").json(200, (body, ctx) => { + object(body) + check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") + check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") + }), + http.get("/vcs", "vcs.get").json(), + http.get("/vcs/diff", "vcs.diff").at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })).json(200, array), + http.get("/command", "command.list").json(200, array, "status"), + http.get("/agent", "app.agents").json(200, array, "status"), + http.get("/skill", "app.skills").json(200, array, "status"), + http.get("/lsp", "lsp.status").json(200, array), + http.get("/formatter", "formatter.status").json(200, array), + http.get("/config", "config.get").json(200, undefined, "status"), + http + .patch("/config", "config.update") + .mutating() + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) + .json(200, (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, "status"), + http + .patch("/config", "config.update.invalid") + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) + .status(400), + http.get("/config/providers", "config.providers").json(), + http.get("/project", "project.list").json(200, array, "status"), + http.get("/project/current", "project.current").json(200, (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, "status"), + http + .patch("/project/{projectID}", "project.update") + .mutating() + .seeded((ctx) => ctx.project()) + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: { name: "HTTP API Project", commands: { start: "bun --version" } }, + })) + .json(200, (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check(isRecord(body.commands) && body.commands.start === "bun --version", "project update should return patched command") + }, "status"), + http + .post("/project/git/init", "project.initGit") + .mutating() + .inProject({ git: false }) + .json(200, (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, "status"), + http.get("/provider", "provider.list").json(), + http.get("/provider/auth", "provider.auth").json(), + http + .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") + .at((ctx) => ({ path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .status(400), + http + .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") + .at((ctx) => ({ path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .status(400), + http.get("/permission", "permission.list").json(200, array), + http + .post("/permission/{requestID}/reply", "permission.reply.invalid") + .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "bad" } })) + .status(400), + http + .post("/permission/{requestID}/reply", "permission.reply") + .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "once" } })) + .json(200, (body) => { + check(body === true, "permission reply should return true even when request is no longer pending") + }), + http.get("/question", "question.list").json(200, array), + http + .post("/question/{requestID}/reply", "question.reply.invalid") + .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: "Yes" } })) + .status(400), + http + .post("/question/{requestID}/reply", "question.reply") + .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: [["Yes"]] } })) + .json(200, (body) => { + check(body === true, "question reply should return true even when request is no longer pending") + }), + http + .post("/question/{requestID}/reject", "question.reject") + .at((ctx) => ({ path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "question reject should return true even when request is no longer pending") + }), + http + .get("/file", "file.list") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/file/content", "file.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) + }), + http + .get("/file/content", "file.read.missing") + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.content === "", "missing file content should return an empty text result") + }), + http.get("/file/status", "file.status").json(200, array), + http + .get("/find", "find.text") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/file", "find.files") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/symbol", "find.symbols") + .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) + .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/event", "event.stream") + .stream() + .status(200, (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status"), + http.get("/mcp", "mcp.status").json(), + http + .post("/mcp", "mcp.add") + .mutating() + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, + })) + .json(200, (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, "status"), + http + .post("/mcp", "mcp.add.invalid") + .at((ctx) => ({ path: "/mcp", headers: ctx.headers(), body: { name: "httpapi-invalid", config: { type: "invalid" } } })) + .status(400), + http + .post("/mcp/{name}/auth", "mcp.auth.start") + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(400, (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, "status"), + http + .delete("/mcp/{name}/auth", "mcp.auth.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.success === true, "MCP auth removal should return success") + }), + http + .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") + .at((ctx) => ({ path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(400, (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, "status"), + http + .post("/mcp/{name}/auth/callback", "mcp.auth.callback") + .at((ctx) => ({ path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), headers: ctx.headers(), body: { code: 1 } })) + .status(400), + http + .post("/mcp/{name}/connect", "mcp.connect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP connect should remain a no-op success") + }), + http + .post("/mcp/{name}/disconnect", "mcp.disconnect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP disconnect should remain a no-op success") + }), + http.get("/pty/shells", "pty.shells").json(200, array), + http.get("/pty", "pty.list").json(200, array), + http + .post("/pty", "pty.create") + .mutating() + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) + .json(200, (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, "status"), + http + .post("/pty", "pty.create.invalid") + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) + .status(400), + http + .get("/pty/{ptyID}", "pty.get") + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .put("/pty/{ptyID}", "pty.update") + .mutating() + .at((ctx) => ({ + path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { size: { rows: 0, cols: 0 } }, + })) + .status(400), + http + .delete("/pty/{ptyID}", "pty.remove") + .mutating() + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "PTY remove should return true") + }), + http + .get("/pty/{ptyID}/connect", "pty.connect") + .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404, undefined, "none"), + http.get("/experimental/console", "experimental.console.get").json(), + http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http + .post("/experimental/console/switch", "experimental.console.switchOrg") + .at((ctx) => ({ path: "/experimental/console/switch", headers: ctx.headers(), body: { accountID: "httpapi-account", orgID: "httpapi-org" } })) + .status(400, undefined, "none"), + http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http + .post("/experimental/workspace", "experimental.workspace.create") + .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) + .status(400), + http + .delete("/experimental/workspace/{id}", "experimental.workspace.remove") + .mutating() + .at((ctx) => ({ path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), headers: ctx.headers() })) + .status(200), + http + .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .at((ctx) => ({ path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), headers: ctx.headers(), body: {} })) + .status(400), + http + .get("/experimental/tool", "tool.list") + .at((ctx) => ({ path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, headers: ctx.headers() })) + .json(200, array, "status"), + http.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.get("/experimental/worktree", "worktree.list").json(200, array), + http + .post("/experimental/worktree", "worktree.create") + .mutating() + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status"), + http + .post("/experimental/worktree", "worktree.create.invalid") + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) + .status(400), + http + .delete("/experimental/worktree", "worktree.remove") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-remove" })) + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .json(200, (body) => { + check(body === true, "worktree remove should return true") + }), + http + .post("/experimental/worktree/reset", "worktree.reset") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-reset" })) + .at((ctx) => ({ path: "/experimental/worktree/reset", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "worktree reset should return true") + yield* ctx.worktreeRemove(ctx.state.directory) + }), + ), + http.get("/experimental/session", "experimental.session.list").json(200, array), + http.get("/experimental/resource", "experimental.resource.list").json(), + http.post("/sync/history", "sync.history.list").at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })).json(200, array), + http + .post("/sync/replay", "sync.replay") + .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) + .status(400), + http.post("/sync/start", "sync.start").mutating().preserveDatabase().json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http.post("/instance/dispose", "instance.dispose").mutating().json(200, (body) => { + check(body === true, "instance dispose should return true") + }), + http + .post("/log", "app.log") + .global() + .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) + .json(200, (body) => { + check(body === true, "log route should return true") + }), + http + .put("/auth/{providerID}", "auth.set") + .global() + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth set should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") + }), + ), + http + .delete("/auth/{providerID}", "auth.remove") + .global() + .seeded(() => + Effect.promise(() => + Bun.write(path.join(exerciseDataDirectory, "auth.json"), JSON.stringify({ test: { type: "api", key: "remove-me" } })), + ), + ) + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth remove should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(auth.test === undefined, "auth remove should delete provider from isolated auth file") + }), + ), + http + .get("/session", "session.list") + .seeded((ctx) => ctx.session({ title: "List me" })) + .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check(body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), "seeded session should be listed") + }), + http + .get("/session/status", "session.status") + .seeded((ctx) => ctx.session({ title: "Status session" })) + .json(200, object), + http + .post("/session", "session.create") + .mutating() + .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) + .json(200, (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, "status"), + http + .get("/session/{sessionID}", "session.get") + .seeded((ctx) => ctx.session({ title: "Get me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "should return requested session") + check(body.title === "Get me", "should preserve seeded title") + }), + http + .get("/session/{sessionID}", "session.get.missing") + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .patch("/session/{sessionID}", "session.update") + .mutating() + .seeded((ctx) => ctx.session({ title: "Before rename" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers(), body: { title: "After rename" } })) + .json(200, (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, "status"), + http + .patch("/session/{sessionID}", "session.update.invalid") + .mutating() + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), body: { title: 1 } })) + .status(400), + http + .delete("/session/{sessionID}", "session.delete") + .mutating() + .seeded((ctx) => ctx.session({ title: "Delete me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete should return true") + check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") + }), + ), + http + .get("/session/{sessionID}/children", "session.children") + .seeded((ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session({ title: "Parent" }) + const child = yield* ctx.session({ title: "Child", parentID: parent.id }) + return { parent, child } + }), + ) + .at((ctx) => ({ path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check(body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), "children should include seeded child") + }), + http + .get("/session/{sessionID}/todo", "session.todo") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Todo session" }) + const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + yield* ctx.todos(session.id, todos) + return { session, todos } + }), + ) + .at((ctx) => ({ path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") + }), + http + .get("/session/{sessionID}/diff", "session.diff") + .seeded((ctx) => ctx.session({ title: "Diff session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, array), + http + .get("/session/{sessionID}/message", "session.messages") + .seeded((ctx) => ctx.session({ title: "Messages session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + array(body) + check(body.length === 0, "new session should have no messages") + }), + http + .get("/session/{sessionID}/message/{messageID}", "session.message") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message get session" }) + const message = yield* ctx.message(session.id, { text: "read me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), "message should include seeded part") + }), + http + .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part update session" }) + const message = yield* ctx.message(session.id, { text: "before" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + body: { ...ctx.state.message.part, text: "after" }, + })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, "status"), + http + .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part delete session" }) + const message = yield* ctx.message(session.id, { text: "delete part" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete part should return true") + const messages = yield* ctx.messages(ctx.state.session.id) + check(messages[0]?.parts.length === 0, "deleted part should not remain on message") + }), + ), + http + .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message delete session" }) + const message = yield* ctx.message(session.id, { text: "delete message" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete message should return true") + check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") + }), + ), + http + .post("/session/{sessionID}/fork", "session.fork") + .mutating() + .seeded((ctx) => ctx.session({ title: "Fork source" })) + .at((ctx) => ({ path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), headers: ctx.headers(), body: {} })) + .json(200, (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, "status"), + http + .post("/session/{sessionID}/abort", "session.abort") + .mutating() + .seeded((ctx) => ctx.session({ title: "Abort session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "abort should return true") + }), + http + .post("/session/{sessionID}/abort", "session.abort.missing") + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing session abort should remain a no-op success") + }), + http + .post("/session/{sessionID}/init", "session.init") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Init session" }) + const message = yield* ctx.message(session.id, { text: "initialize" }) + yield* ctx.llmText("initialized") + yield* ctx.llmText("initialized") + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "init should return true") + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/message", "session.prompt") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), "assistant message should use fake LLM text") + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/prompt_async", "session.prompt_async") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Async prompt session" }) + yield* ctx.llmText("fake async assistant") + yield* ctx.llmText("fake async assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello async" }], + }, + })) + .status(204, (ctx) => + Effect.gen(function* () { + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/command", "session.command") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Command session" }) + yield* ctx.llmText("command done") + yield* ctx.llmText("command done") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { command: "init", arguments: "", model: "test/test-model" }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/shell", "session.shell") + .preserveDatabase() + .mutating() + .seeded((ctx) => ctx.session({ title: "Shell session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, + })) + .json(200, (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), "shell should return a tool part") + }, "status"), + http + .post("/session/{sessionID}/summarize", "session.summarize") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Summarize session" }) + yield* ctx.message(session.id, { text: "summarize this work" }) + const summary = [ + "## Goal", + "- Exercise session summarize.", + "", + "## Constraints & Preferences", + "- Use fake LLM.", + "", + "## Progress", + "### Done", + "- Summary generated.", + "", + "### In Progress", + "- (none)", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- Keep route local.", + "", + "## Next Steps", + "- (none)", + "", + "## Critical Context", + "- Test fixture.", + "", + "## Relevant Files", + "- script/httpapi-exercise.ts: scenario", + ].join("\n") + yield* ctx.llmText(summary) + yield* ctx.llmText(summary) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", auto: false }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/revert", "session.revert") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Revert session" }) + const message = yield* ctx.message(session.id, { text: "revert me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { messageID: ctx.state.message.info.id }, + })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check(isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, "revert should record reverted message") + }, "status"), + http + .post("/session/{sessionID}/unrevert", "session.unrevert") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unrevert session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, "status"), + http + .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") + .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/permissions/{permissionID}", { sessionID: ctx.state.id, permissionID: "per_httpapi_deprecated" }), + headers: ctx.headers(), + body: { response: "once" }, + })) + .json(200, (body) => { + check(body === true, "deprecated permission response should return true") + }), + http + .post("/session/{sessionID}/share", "session.share") + .mutating() + .seeded((ctx) => ctx.session({ title: "Share session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, "status"), + http + .delete("/session/{sessionID}/share", "session.unshare") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unshare session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, "status"), + http + .post("/tui/append-prompt", "tui.appendPrompt") + .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession.invalid") + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) + .status(400), + http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http + .post("/tui/execute-command", "tui.executeCommand") + .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) + .json(200, boolean, "status"), + http + .post("/tui/show-toast", "tui.showToast") + .at((ctx) => ({ + path: "/tui/show-toast", + headers: ctx.headers(), + body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, + })) + .json(200, boolean, "status"), + http + .post("/tui/publish", "tui.publish") + .at((ctx) => ({ + path: "/tui/publish", + headers: ctx.headers(), + body: { type: "tui.prompt.append", properties: { text: "published" } }, + })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession") + .seeded((ctx) => ctx.session({ title: "TUI select" })) + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) + .json(200, boolean, "status"), + http + .post("/tui/control/response", "tui.control.response") + .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) + .json(200, boolean, "status"), + http + .get("/tui/control/next", "tui.control.next") + .mutating() + .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) + .json(200, (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, "status"), + http.post("/global/upgrade", "global.upgrade").global().at(() => ({ path: "/global/upgrade", body: { target: 1 } })).status(400), +] + +const main = Effect.gen(function* () { + yield* Effect.addFinalizer(() => cleanupExercisePaths) + const options = parseOptions(Bun.argv.slice(2)) + const modules = yield* Effect.promise(() => runtime()) + const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi())) + const selected = scenarios.filter((scenario) => matches(options, scenario)) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) + + const results = options.mode === "coverage" ? selected.map(coverageResult) : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + printResults(results, missing, extra) + + if (results.some((result) => result.status === "fail")) return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) return yield* Effect.fail(new Error("one or more routes have no scenario")) +}) + +function runScenario(options: Options) { + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) + return runActive(options, scenario).pipe( + Effect.as({ status: "pass", scenario } as Result), + Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), + Effect.scoped, + ) + } +} + +function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { + return Effect.gen(function* () { + const effect = yield* runBackend("effect", scenario) + const legacy = yield* runBackend("legacy", scenario) + yield* compare(scenario, effect, legacy) + }) + } + + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const effect = yield* call("effect", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, effect) + if (options.mode === "parity" && scenario.compare !== "none") { + const legacy = yield* call("legacy", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, legacy) + yield* compare(scenario, effect, legacy) + } + }), + ) +} + +function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const result = yield* call(backend, scenario, ctx) + yield* scenario.expect(ctx, ctx.state, result) + return result + }), + ) +} + +function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { + return Effect.acquireRelease( + Effect.gen(function* () { + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + return { dir, llm } + }), + (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((context) => Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), + ), + ), + ) + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), ...extra }), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => + run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => + run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => + run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void))), + Effect.ensuring(scenario.reset ? resetState : Effect.void), + ) +} + +function projectOptions(project: ProjectOptions, llmUrl: string | undefined): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +function controlledPtyInput(title: string | undefined) { + return { + command: "/bin/sh", + args: ["-c", "sleep 30"], + ...(title ? { title } : {}), + } +} + +function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { + return Effect.promise(async () => capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture)) +} + +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = undefined + if (appCache[backend]) return appCache[backend] + if (backend === "legacy") { + const legacy = modules.Server.Legacy().app + return (appCache.legacy = { + request: (input, init) => legacy.request(input, init), + }) + } + + const handler = HttpRouter.toWebHandler( + modules.ExperimentalHttpApiServer.routes.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }))), + ), + { disableLogger: true }, + ).handler + return (appCache.effect = { + request(input: string | URL | Request, init?: RequestInit) { + return handler(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), modules.ExperimentalHttpApiServer.context) + }, + }) +} + +function toRequest(scenario: ActiveScenario, ctx: SeededContext) { + const spec = scenario.request(ctx, ctx.state) + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }) +} + +async function capture(response: Response, mode: CaptureMode): Promise { + const text = mode === "stream" ? await captureStream(response) : await response.text() + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + text, + body: parse(text), + } +} + +async function captureStream(response: Response) { + if (!response.body) return "" + const reader = response.body.getReader() + const read = reader.read().then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) + if ("timeout" in winner) { + await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) + throw new Error("timed out waiting for stream chunk") + } + if ("error" in winner) throw winner.error + await reader.cancel().catch(() => undefined) + if (winner.result.done) return "" + return new TextDecoder().decode(winner.result.value) +} + +const cleanupExercisePaths = Effect.promise(async () => { + const fs = await import("fs/promises") + if (!preserveExerciseDatabase) { + await Promise.all([exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => fs.rm(file, { force: true }).catch(() => undefined))) + } + if (!preserveExerciseGlobalRoot) await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) +}) + +function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { + return Effect.sync(() => { + if (effect.status !== legacy.status) throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (scenario.compare === "status") return + if (stable(effect.body) !== stable(legacy.body)) throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + }) +} + +const resetState = Effect.promise(async () => { + const modules = await runtime() + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await modules.disposeAllInstances() + await modules.resetDatabase() + await Bun.sleep(25) +}) + +function routeKeys(spec: OpenApiSpec) { + return Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .sort() +} + +function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} + +function coverageResult(scenario: Scenario): Result { + if (scenario.kind === "todo") return { status: "skip", scenario } + return { status: "pass", scenario } +} + +function parseOptions(args: string[]): Options { + const mode = option(args, "--mode") ?? "effect" + if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) + return { + mode, + include: option(args, "--include"), + failOnMissing: args.includes("--fail-on-missing"), + failOnSkip: args.includes("--fail-on-skip"), + } +} + +function option(args: string[], name: string) { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +function matches(options: Options, scenario: Scenario) { + if (!options.include) return true + return scenario.name.includes(options.include) || scenario.path.includes(options.include) || scenario.method.includes(options.include.toUpperCase()) +} + +function printHeader(options: Options, effectRoutes: string[], honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[]) { + console.log(`${color.cyan}HttpApi exerciser${color.reset}`) + console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) + console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) + console.log( + `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + ) + console.log("") +} + +function printResults(results: Result[], missing: string[], extra: Scenario[]) { + for (const result of results) { + if (result.status === "pass") { + console.log(`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + continue + } + if (result.status === "skip") { + console.log(`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`) + continue + } + console.log(`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log(`${color.red}${indent(result.message)}${color.reset}`) + } + if (missing.length > 0) { + console.log("\nMissing scenarios") + for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) + } + if (extra.length > 0) { + console.log("\nExtra scenarios") + for (const scenario of extra) console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + } + console.log( + `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) +} + +function parse(text: string): unknown { + if (!text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +function looksJson(result: CallResult) { + return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") +} + +function stable(value: unknown): string { + return JSON.stringify(sort(value)) +} + +function sort(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sort) + if (!value || typeof value !== "object") return value + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sort(item)])) +} + +function array(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) throw new Error("expected array") +} + +function object(value: unknown): asserts value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") +} + +function boolean(value: unknown): asserts value is boolean { + if (typeof value !== "boolean") throw new Error("expected boolean") +} + +function isRecord(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function check(value: boolean, message: string): asserts value { + if (!value) throw new Error(message) +} + +function message(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +function pad(value: string, size: number) { + return value.length >= size ? value : value + " ".repeat(size - value.length) +} + +function indent(value: string) { + return value + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} + +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( + () => process.exit(0), + (error: unknown) => { + console.error(`${color.red}${message(error)}${color.reset}`) + process.exit(1) + }, +) diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 48399a5f4d..d2be015211 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -26,13 +26,17 @@ export function nextTuiRequest() { return request.next() } +export function submitTuiRequest(body: TuiRequest) { + request.push(body) +} + export function submitTuiResponse(body: unknown) { response.push(body) } export async function callTui(ctx: Context) { const body = await ctx.req.json() - request.push({ + submitTuiRequest({ path: ctx.req.path, body, }) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index de4683b751..06cb99f97f 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -122,6 +122,7 @@ export const Client = lazy(() => { }) export function close() { + if (!Client.loaded()) return Client().$client.close() Client.reset() } diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 86967e11a0..d9abf18a52 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -14,5 +14,7 @@ export function lazy(fn: () => T) { value = undefined } + result.loaded = () => loaded + return result } diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 00564a17bf..41372b15a0 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s ```typescript import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) describe("my service", () => { - it.live("does the thing", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const svc = yield* MyService.Service - const out = yield* svc.run() - expect(out).toEqual("ok") - }), - ), + it.instance("does the thing", () => + Effect.gen(function* () { + const svc = yield* MyService.Service + const out = yield* svc.run() + expect(out).toEqual("ok") + }), ) }) ``` @@ -111,6 +108,7 @@ describe("my service", () => { - Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. - Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. +- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context. - Most integration-style tests in this package use `it.live(...)`. ### Effect Fixtures @@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. -Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. +Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path: + +```typescript +import { TestInstance } from "../fixture/fixture" + +it.instance("uses the temp directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect(test.directory).toContain("opencode-test-") + }), +) +``` + +Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime. ### Style @@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst - Keep the test body inside `Effect.gen(function* () { ... })`. - Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. -- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. +- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 101d3be72b..377c541096 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -2,9 +2,8 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node) const it = testEffect(live) describe("Bus (Effect-native)", () => { - it.live("publish + subscribe stream delivers events", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() + it.instance("publish + subscribe stream delivers events", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Ping, { value: 2 }) + yield* Deferred.await(done) - expect(received).toEqual([1, 2]) - }), - ), + expect(received).toEqual([1, 2]) + }), ) - it.live("subscribe filters by event type", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() + it.instance("subscribe filters by event type", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const pings: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + pings.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Pong, { message: "ignored" }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Pong, { message: "ignored" }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* Deferred.await(done) - expect(pings).toEqual([42]) - }), - ), + expect(pings).toEqual([42]) + }), ) - it.live("subscribeAll receives all types", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const types: string[] = [] - const done = yield* Deferred.make() + it.instance("subscribeAll receives all types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const types: string[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribeAll(), (evt) => - Effect.sync(() => { - types.push(evt.type) - if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribeAll(), (evt) => + Effect.sync(() => { + types.push(evt.type) + if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Pong, { message: "hi" }) + yield* Deferred.await(done) - expect(types).toContain("test.effect.ping") - expect(types).toContain("test.effect.pong") - }), - ), + expect(types).toContain("test.effect.ping") + expect(types).toContain("test.effect.pong") + }), ) - it.live("multiple subscribers each receive the event", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const a: number[] = [] - const b: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() + it.instance("multiple subscribers each receive the event", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const a: number[] = [] + const b: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + a.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + b.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(doneA) - yield* Deferred.await(doneB) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 99 }) + yield* Deferred.await(doneA) + yield* Deferred.await(doneB) - expect(a).toEqual([99]) - expect(b).toEqual([99]) - }), - ), + expect(a).toEqual([99]) + expect(b).toEqual([99]) + }), ) it.live("subscribeAll stream sees InstanceDisposed on disposal", () => diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index e6c8aebcbd..970365f533 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -6,6 +6,7 @@ import path from "path" import { Effect, Context, Layer, ManagedRuntime } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" @@ -184,6 +185,21 @@ export function provideTmpdirInstance( }) } +export class TestInstance extends Context.Service()("@test/Instance") {} + +export const withTmpdirInstance = + (options?: { git?: boolean; config?: Partial }) => + (self: Effect.Effect) => + Effect.gen(function* () { + const directory = yield* tmpdirScoped(options) + return yield* InstanceStore.Service.use((store) => + store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))), + ) + }).pipe( + Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))), + Effect.provide(CrossSpawnSpawner.defaultLayer), + ) + export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, options?: { git?: boolean; config?: (url: string) => Partial }, diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 131ec5cc6b..2fbf5ca11b 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -3,8 +3,24 @@ import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" +import type { Config } from "@/config/config" +import { TestInstance, withTmpdirInstance } from "../fixture/fixture" type Body = Effect.Effect | (() => Effect.Effect) +type InstanceOptions = { git?: boolean; config?: Partial } + +function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { + return !!options && typeof options === "object" && ("git" in options || "config" in options) +} + +function instanceArgs( + options?: InstanceOptions | number | TestOptions, + testOptions?: number | TestOptions, +): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { + if (typeof options === "number") return { instanceOptions: undefined, testOptions: options } + if (isInstanceOptions(options)) return { instanceOptions: options, testOptions } + return { instanceOptions: undefined, testOptions: options } +} const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) @@ -38,7 +54,37 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) live.skip = (name: string, value: Body, opts?: number | TestOptions) => test.skip(name, () => run(value, liveLayer), opts) - return { effect, live } + const instance = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + instance.only = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.only(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + instance.skip = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.skip(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + return { effect, live, instance } } // Test environment with TestClock and TestConsole diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 694a37e99f..461fb88f26 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,65 +1,64 @@ -import { afterEach, test, expect } from "bun:test" +import { afterEach, expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" +import { testEffect } from "../lib/effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -const ask = (input: { sessionID: SessionID; questions: ReadonlyArray; tool?: Question.Tool }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) +const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) +const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Question.Tool +}) { + const question = yield* Question.Service + return yield* question.ask(input) +}) -const reply = (input: { requestID: QuestionID; answers: ReadonlyArray }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input))) +const listEffect = Question.Service.use((svc) => svc.list()) -const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) +const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: { + requestID: QuestionID + answers: ReadonlyArray +}) { + const question = yield* Question.Service + yield* question.reply(input) +}) + +const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) { + const question = yield* Question.Service + yield* question.reject(id) +}) afterEach(async () => { await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ -async function rejectAll() { - const pending = await list() - for (const req of pending) { - await reject(req.id) - } -} - -test("ask - returns pending promise", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - expect(promise).toBeInstanceOf(Promise) - await rejectAll() - await promise.catch(() => {}) - }, - }) +const rejectAll = Effect.gen(function* () { + yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true }) }) -test("ask - adds to pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +const waitForPending = (count: number) => + Effect.gen(function* () { + for (let i = 0; i < 100; i++) { + const pending = yield* listEffect + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) + }) + +it.instance("ask - remains pending until answered", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", @@ -68,30 +67,81 @@ test("ask - adds to pending list", async () => { { label: "Option 2", description: "Second option" }, ], }, - ] + ], + }).pipe(Effect.forkScoped) - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) - const pending = await list() - expect(pending.length).toBe(1) - expect(pending[0].questions).toEqual(questions) - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) +it.instance("ask - adds to pending list", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + expect(pending[0].questions).toEqual(questions) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) // reply tests -test("reply - resolves the pending ask with answers", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +it.instance("reply - resolves the pending ask with answers", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + const requestID = pending[0].id + + yield* replyEffect({ + requestID, + answers: [["Option 1"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), + { git: true }, +) + +it.instance("reply - removes from pending list", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", @@ -100,366 +150,260 @@ test("reply - resolves the pending ask with answers", async () => { { label: "Option 2", description: "Second option" }, ], }, - ] + ], + }).pipe(Effect.forkScoped) - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - const pending = await list() - const requestID = pending[0].id + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Option 1"]], + }) + yield* Fiber.join(fiber) - await reply({ - requestID, - answers: [["Option 1"]], - }) + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) - const answers = await promise - expect(answers).toEqual([["Option 1"]]) - }, - }) -}) - -test("reply - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - expect(pending.length).toBe(1) - - await reply({ - requestID: pending[0].id, - answers: [["Option 1"]], - }) - await promise - - const after = await list() - expect(after.length).toBe(0) - }, - }) -}) - -test("reply - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reply({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }) - // Should not throw - }, - }) -}) +it.instance("reply - does nothing for unknown requestID", () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), + { git: true }, +) // reject tests -test("reject - throws RejectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - await reject(pending[0].id) - - await expect(promise).rejects.toBeInstanceOf(Question.RejectedError) - }, - }) -}) - -test("reject - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - expect(pending.length).toBe(1) - - await reject(pending[0].id) - promise.catch(() => {}) // Ignore rejection - - const after = await list() - expect(after.length).toBe(0) - }, - }) -}) - -test("reject - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reject(QuestionID.make("que_unknown")) - // Should not throw - }, - }) -}) - -// multiple questions tests - -test("ask - handles multiple questions", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +it.instance("reject - throws RejectedError", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", options: [ - { label: "Build", description: "Build the project" }, - { label: "Test", description: "Run tests" }, + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, ], }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) + + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), + { git: true }, +) + +it.instance("reject - removes from pending list", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { - question: "Which environment?", - header: "Env", + question: "What would you like to do?", + header: "Action", options: [ - { label: "Dev", description: "Development" }, - { label: "Prod", description: "Production" }, + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, ], }, - ] + ], + }).pipe(Effect.forkScoped) - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - const pending = await list() + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - await reply({ - requestID: pending[0].id, - answers: [["Build"], ["Dev"]], - }) + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) - const answers = await promise - expect(answers).toEqual([["Build"], ["Dev"]]) - }, - }) -}) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true }) + +// multiple questions tests + +it.instance("ask - handles multiple questions", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Build", description: "Build the project" }, + { label: "Test", description: "Run tests" }, + ], + }, + { + question: "Which environment?", + header: "Env", + options: [ + { label: "Dev", description: "Development" }, + { label: "Prod", description: "Production" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Build"], ["Dev"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), + { git: true }, +) // list tests -test("list - returns all pending requests", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const p1 = ask({ - sessionID: SessionID.make("ses_test1"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }) +it.instance("list - returns all pending requests", () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_test1"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(Effect.forkScoped) - const p2 = ask({ - sessionID: SessionID.make("ses_test2"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_test2"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(Effect.forkScoped) - const pending = await list() - expect(pending.length).toBe(2) - await rejectAll() - p1.catch(() => {}) - p2.catch(() => {}) - }, - }) -}) + const pending = yield* waitForPending(2) + expect(pending.length).toBe(2) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), + { git: true }, +) -test("list - returns empty when no pending", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await list() - expect(pending.length).toBe(0) - }, - }) -}) +it.instance("list - returns empty when no pending", () => + Effect.gen(function* () { + const pending = yield* listEffect + expect(pending.length).toBe(0) + }), + { git: true }, +) -test("questions stay isolated by directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) +it.live("questions stay isolated by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped({ git: true }) + const two = yield* tmpdirScoped({ git: true }) - const p1 = Instance.provide({ - directory: one.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_one"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }), - }) + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_one"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(provideInstance(one), Effect.forkScoped) - const p2 = Instance.provide({ - directory: two.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_two"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }), - }) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_two"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(provideInstance(two), Effect.forkScoped) - const onePending = await Instance.provide({ - directory: one.path, - fn: () => list(), - }) - const twoPending = await Instance.provide({ - directory: two.path, - fn: () => list(), - }) + const onePending = yield* waitForPending(1).pipe(provideInstance(one)) + const twoPending = yield* waitForPending(1).pipe(provideInstance(two)) - expect(onePending.length).toBe(1) - expect(twoPending.length).toBe(1) - expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) - expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + expect(onePending.length).toBe(1) + expect(twoPending.length).toBe(1) + expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) + expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) - await Instance.provide({ - directory: one.path, - fn: () => reject(onePending[0].id), - }) - await Instance.provide({ - directory: two.path, - fn: () => reject(twoPending[0].id), - }) + yield* rejectEffect(onePending[0].id).pipe(provideInstance(one)) + yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two)) - await p1.catch(() => {}) - await p2.catch(() => {}) -}) + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), +) -test("pending question rejects on instance dispose", async () => { - await using tmp = await tmpdir({ git: true }) +it.live("pending question rejects on instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_dispose"), + questions: [ + { + question: "Dispose me?", + header: "Dispose", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_dispose"), - questions: [ - { - question: "Dispose me?", - header: "Dispose", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceRuntime.disposeInstance(Instance.current) - }, - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) - expect(await result).toBeInstanceOf(Question.RejectedError) -}) +it.live("pending question rejects on instance reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_reload"), + questions: [ + { + question: "Reload me?", + header: "Reload", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) -test("pending question rejects on instance reload", async () => { - await using tmp = await tmpdir({ git: true }) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_reload"), - questions: [ - { - question: "Reload me?", - header: "Reload", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceRuntime.reloadInstance({ directory: tmp.path }) - }, - }) - - expect(await result).toBeInstanceOf(Question.RejectedError) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) diff --git a/packages/opencode/test/server/global-bus.ts b/packages/opencode/test/server/global-bus.ts new file mode 100644 index 0000000000..c8d0f92191 --- /dev/null +++ b/packages/opencode/test/server/global-bus.ts @@ -0,0 +1,34 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Cause, Effect } from "effect" + +export function waitGlobalBusEvent(input: { + timeout?: number + message?: string + predicate: (event: GlobalEvent) => boolean +}) { + return Effect.callback((resume) => { + const cleanup = () => GlobalBus.off("event", handler) + + const handler = (event: GlobalEvent) => { + try { + if (!input.predicate(event)) return + cleanup() + resume(Effect.succeed(event)) + } catch (error) { + cleanup() + resume(Effect.fail(error)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(cleanup) + }).pipe( + Effect.timeout(input.timeout ?? 10_000), + Effect.mapError((error) => + Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error, + ), + ) +} + +export const waitGlobalBusEventPromise = (input: Parameters[0]) => + Effect.runPromise(waitGlobalBusEvent(input)) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 7d269b6bed..16e8975ea1 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 0185af2df9..5f36a32746 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" @@ -11,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -31,20 +31,9 @@ function createSession(input?: Session.CreateInput) { } async function waitReady(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for worktree.ready")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for worktree.ready", + predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory, }) } diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index f311de2b4a..7a889aea04 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,6 +1,5 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { describe, expect } from "bun:test" import { Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -19,6 +18,7 @@ import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpa import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { waitGlobalBusEvent } from "./global-bus" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -95,24 +95,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => Layer.build, ) -const waitDisposedEvent = Effect.promise( - () => - new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed") return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve({ directory: event.directory, workspace: event.workspace }) - } - - GlobalBus.on("event", onEvent) - }), -) +const waitDisposedEvent = waitGlobalBusEvent({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", +}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace }))) const serveDisposeProbe = () => HttpRouter.serve( diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts index 22a56ba8a4..b5f0805e4c 100644 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } @@ -117,13 +105,9 @@ describe("instance HttpApi", () => { test("serves instance dispose through Hono bridge", async () => { await using tmp = await tmpdir() - const disposed = new Promise((resolve) => { - const onEvent = (event: { directory?: string; payload: { type?: string } }) => { - if (event.payload.type !== "server.instance.disposed") return - GlobalBus.off("event", onEvent) - resolve(event.directory) - } - GlobalBus.on("event", onEvent) + const disposed = waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", }) const response = await app().request(InstancePaths.dispose, { @@ -133,6 +117,6 @@ describe("instance HttpApi", () => { expect(response.status).toBe(200) expect(await response.json()).toBe(true) - expect(await disposed).toBe(tmp.path) + expect((await disposed).directory).toBe(tmp.path) }) }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 1fd3ce2b39..1b9e1c1503 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "../../src/bus/global" import { TuiEvent } from "../../src/cli/cmd/tui/event" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" @@ -12,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -23,14 +23,9 @@ function app(experimental = true) { } function nextCommandExecute() { - return new Promise((resolve) => { - const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { - if (event.payload.type !== TuiEvent.CommandExecute.type) return - GlobalBus.off("event", listener) - resolve(event.payload.properties?.command) - } - GlobalBus.on("event", listener) - }) + return waitGlobalBusEventPromise({ + predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, + }).then((event) => event.payload.properties?.command) } async function expectTrue(path: string, headers: Record, body?: unknown) { diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 028436d295..94f401afd8 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( @@ -33,49 +33,47 @@ const ctx = { } describe("tool.glob", () => { - it.live("matches files from a directory path", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) - yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const result = yield* glob.execute( + it.instance("matches files from a directory path", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* glob.execute( + { + pattern: "*.ts", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(test.directory, "a.ts")) + expect(result.output).not.toContain(path.join(test.directory, "b.txt")) + }), + ) + + it.instance("rejects exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "a.ts") + yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const exit = yield* glob + .execute( { pattern: "*.ts", - path: dir, + path: file, }, ctx, ) - expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(dir, "a.ts")) - expect(result.output).not.toContain(path.join(dir, "b.txt")) - }), - ), - ) - - it.live("rejects exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "a.ts") - yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const exit = yield* glob - .execute( - { - pattern: "*.ts", - path: file, - }, - ctx, - ) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") - } - }), - ), + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") + } + }), ) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index c807d12812..4b0da7c698 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { provideInstance, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" @@ -54,61 +54,58 @@ describe("tool.grep", () => { }), ) - it.live("no matches returns correct output", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "xyznonexistentpatternxyz123", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBe(0) - expect(result.output).toBe("No files found") - }), - ), + it.instance("no matches returns correct output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "xyznonexistentpatternxyz123", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBe(0) + expect(result.output).toBe("No files found") + }), ) - it.live("finds matches in tmp instance", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBeGreaterThan(0) - }), - ), + it.instance("finds matches in tmp instance", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + }), ) - it.live("supports exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "test.txt") - yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line2", - path: file, - }, - ctx, - ) - expect(result.metadata.matches).toBe(1) - expect(result.output).toContain(file) - expect(result.output).toContain("Line 2: line2") - }), - ), + it.instance("supports exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "test.txt") + yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line2", + path: file, + }, + ctx, + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(file) + expect(result.output).toContain("Line 2: line2") + }), ) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 662073a8c3..3f2cba8941 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest }) describe("tool.question", () => { - it.live("should successfully execute with valid question parameters", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite color?", - header: "Color", - options: [ - { label: "Red", description: "The color of passion" }, - { label: "Blue", description: "The color of sky" }, - ], - multiple: false, - }, - ] + it.instance("should successfully execute with valid question parameters", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Red"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Red"]] }) - const result = yield* Fiber.join(fiber) - expect(result.title).toBe("Asked 1 question") - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.title).toBe("Asked 1 question") + }), ) - it.live("should now pass with a header longer than 12 but less than 30 chars", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite animal?", - header: "This Header is Over 12", - options: [{ label: "Dog", description: "Man's best friend" }], - }, - ] + it.instance("should now pass with a header longer than 12 but less than 30 chars", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) - const result = yield* Fiber.join(fiber) - expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }), ) // intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 3fa61401e1..695d96ec2f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,7 +13,7 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => { }) describe("tool.read truncation", () => { - it.live("truncates large file by bytes and sets truncated metadata", () => + it.instance("truncates large file by bytes and sets truncated metadata", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const base = yield* load(path.join(FIXTURES_DIR, "models-api.json")) const target = 60 * 1024 const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) - yield* put(path.join(dir, "large.json"), content) + yield* put(path.join(test.directory, "large.json"), content) - const result = yield* exec(dir, { filePath: path.join(dir, "large.json") }) + const result = yield* run({ filePath: path.join(test.directory, "large.json") }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Output capped at") expect(result.output).toContain("Use offset=") }), ) - it.live("truncates by line count when limit is specified", () => + it.instance("truncates by line count when limit is specified", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - yield* put(path.join(dir, "many-lines.txt"), lines) + yield* put(path.join(test.directory, "many-lines.txt"), lines) - const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 }) + const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Showing lines 1-10 of 100") expect(result.output).toContain("Use offset=11") @@ -286,12 +286,12 @@ describe("tool.read truncation", () => { }), ) - it.live("does not truncate small file", () => + it.instance("does not truncate small file", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* put(path.join(dir, "small.txt"), "hello world") + const test = yield* TestInstance + yield* put(path.join(test.directory, "small.txt"), "hello world") - const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") }) + const result = yield* run({ filePath: path.join(test.directory, "small.txt") }) expect(result.metadata.truncated).toBe(false) expect(result.output).toContain("End of file") }), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index f9ac07831a..c33981ddff 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -57,136 +56,133 @@ afterEach(async () => { }) describe("tool.registry", () => { - it.live("loads tools from .opencode/tool (singular)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tool = path.join(opencode, "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tool (singular)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tool = path.join(opencode, "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools from .opencode/tools (plural)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tools (plural)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools with external dependencies without crashing", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, + it.instance("loads tools with external dependencies without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", }, }, - }), - ), - ) + }, + }), + ), + ) - const cowsay = path.join(opencode, "node_modules", "cowsay") - yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("cowsay") - }), - ), + const cowsay = path.join(opencode, "node_modules", "cowsay") + yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("cowsay") + }), ) }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 4931d2a544..8bba52a4b2 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* ( describe("tool.write", () => { describe("new file creation", () => { - it.live("writes content to new file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "newfile.txt") - const result = yield* run({ filePath: filepath, content: "Hello, World!" }) + it.instance("writes content to new file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "newfile.txt") + const result = yield* run({ filePath: filepath, content: "Hello, World!" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(false) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(false) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("Hello, World!") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("Hello, World!") + }), ) - it.live("creates parent directories if needed", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "nested", "deep", "file.txt") - yield* run({ filePath: filepath, content: "nested content" }) + it.instance("creates parent directories if needed", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "nested", "deep", "file.txt") + yield* run({ filePath: filepath, content: "nested content" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("nested content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("nested content") + }), ) - it.live("handles relative paths by resolving to instance directory", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* run({ filePath: "relative.txt", content: "relative content" }) + it.instance("handles relative paths by resolving to instance directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* run({ filePath: "relative.txt", content: "relative content" }) - const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) - expect(content).toBe("relative content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8")) + expect(content).toBe("relative content") + }), ) }) describe("existing file overwrite", () => { - it.live("overwrites existing file content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "existing.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new content" }) + it.instance("overwrites existing file content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new content" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(true) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(true) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("new content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("new content") + }), ) - it.live("preserves BOM when overwriting existing files", () => - provideTmpdirInstance((dir) => + it.instance("preserves BOM when overwriting existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ) + + it.instance( + "restores BOM after formatter strips it", + () => Effect.gen(function* () { - const filepath = path.join(dir, "existing.cs") + const test = yield* TestInstance + const filepath = path.join(test.directory, "formatted.cs") const bom = String.fromCharCode(0xfeff) yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) @@ -127,165 +140,138 @@ describe("tool.write", () => { expect(content.charCodeAt(0)).toBe(0xfeff) expect(content.slice(1)).toBe("using Up;\n") }), - ), - ) - - it.live("restores BOM after formatter strips it", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "formatted.cs") - const bom = String.fromCharCode(0xfeff) - yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) - - yield* run({ filePath: filepath, content: "using Up;\n" }) - - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\n") - }), - { - config: { - formatter: { - stripbom: { - extensions: [".cs"], - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", - "$FILE", - ], - }, + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], }, }, }, - ), + }, ) - it.live("returns diff in metadata for existing files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "file.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new" }) + it.instance("returns diff in metadata for existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new" }) - expect(result.metadata).toHaveProperty("filepath", filepath) - expect(result.metadata).toHaveProperty("exists", true) - }), - ), + expect(result.metadata).toHaveProperty("filepath", filepath) + expect(result.metadata).toHaveProperty("exists", true) + }), ) }) describe("file permissions", () => { - it.live("sets file permissions when writing sensitive data", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "sensitive.json") - yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) + it.instance("sets file permissions when writing sensitive data", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "sensitive.json") + yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) - if (process.platform !== "win32") { - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.mode & 0o777).toBe(0o644) - } - }), - ), + if (process.platform !== "win32") { + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.mode & 0o777).toBe(0o644) + } + }), ) }) describe("content types", () => { - it.live("writes JSON content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "data.json") - const data = { key: "value", nested: { array: [1, 2, 3] } } - yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) + it.instance("writes JSON content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "data.json") + const data = { key: "value", nested: { array: [1, 2, 3] } } + yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(JSON.parse(content)).toEqual(data) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(JSON.parse(content)).toEqual(data) + }), ) - it.live("writes binary-safe content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "binary.bin") - const content = "Hello\x00World\x01\x02\x03" - yield* run({ filePath: filepath, content }) + it.instance("writes binary-safe content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "binary.bin") + const content = "Hello\x00World\x01\x02\x03" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) - it.live("writes empty content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "empty.txt") - yield* run({ filePath: filepath, content: "" }) + it.instance("writes empty content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "empty.txt") + yield* run({ filePath: filepath, content: "" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("") - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.size).toBe(0) - }), - ), + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.size).toBe(0) + }), ) - it.live("writes multi-line content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "multiline.txt") - const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") - yield* run({ filePath: filepath, content: lines }) + it.instance("writes multi-line content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "multiline.txt") + const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") + yield* run({ filePath: filepath, content: lines }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe(lines) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe(lines) + }), ) - it.live("handles different line endings", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "crlf.txt") - const content = "Line 1\r\nLine 2\r\nLine 3" - yield* run({ filePath: filepath, content }) + it.instance("handles different line endings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "crlf.txt") + const content = "Line 1\r\nLine 2\r\nLine 3" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) }) describe("error handling", () => { - it.live("throws error when OS denies write access", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const readonlyPath = path.join(dir, "readonly.txt") - yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) - yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) - const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) - expect(exit._tag).toBe("Failure") - }), - ), + it.instance("throws error when OS denies write access", () => + Effect.gen(function* () { + const test = yield* TestInstance + const readonlyPath = path.join(test.directory, "readonly.txt") + yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) + yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) + const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), ) }) describe("title generation", () => { - it.live("returns relative path as title", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "src", "components", "Button.tsx") - yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) + it.instance("returns relative path as title", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "src", "components", "Button.tsx") + yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) - const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) - expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) - }), - ), + const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) + expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) + }), ) }) }) From a6464062b7b28a3b0e0637166c73eadef1ebe878 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:32:24 +0000 Subject: [PATCH 02/19] chore: generate --- packages/opencode/script/httpapi-exercise.ts | 931 ++++++++++++------ packages/opencode/test/lib/effect.ts | 18 +- .../opencode/test/question/question.test.ts | 418 ++++---- 3 files changed, 853 insertions(+), 514 deletions(-) diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index f0faa27602..1681f2e212 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -32,7 +32,9 @@ import type { Project } from "../src/project/project" import path from "path" const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL -const exerciseGlobalRoot = process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +const exerciseGlobalRoot = + process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") @@ -42,7 +44,9 @@ const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencod const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB -const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +const exerciseDatabasePath = + process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) process.env.OPENCODE_DB = exerciseDatabasePath Flag.OPENCODE_DB = exerciseDatabasePath @@ -167,21 +171,21 @@ const original = { } type Runtime = { - PublicApi: typeof import("../src/server/routes/instance/httpapi/public")["PublicApi"] - ExperimentalHttpApiServer: typeof import("../src/server/routes/instance/httpapi/server")["ExperimentalHttpApiServer"] - Server: typeof import("../src/server/server")["Server"] - AppLayer: typeof import("../src/effect/app-runtime")["AppLayer"] - InstanceRef: typeof import("../src/effect/instance-ref")["InstanceRef"] - Instance: typeof import("../src/project/instance")["Instance"] - InstanceStore: typeof import("../src/project/instance-store")["InstanceStore"] - Session: typeof import("../src/session/session")["Session"] - Todo: typeof import("../src/session/todo")["Todo"] - Worktree: typeof import("../src/worktree")["Worktree"] - Project: typeof import("../src/project/project")["Project"] + PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"] + ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + Server: (typeof import("../src/server/server"))["Server"] + AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"] + InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"] + Instance: (typeof import("../src/project/instance"))["Instance"] + InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"] + Session: (typeof import("../src/session/session"))["Session"] + Todo: (typeof import("../src/session/todo"))["Todo"] + Worktree: (typeof import("../src/worktree"))["Worktree"] + Project: (typeof import("../src/project/project"))["Project"] Tui: typeof import("../src/server/routes/instance/tui") - disposeAllInstances: typeof import("../test/fixture/fixture")["disposeAllInstances"] - tmpdir: typeof import("../test/fixture/fixture")["tmpdir"] - resetDatabase: typeof import("../test/fixture/db")["resetDatabase"] + disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] + tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] + resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] } let runtimePromise: Promise | undefined @@ -276,7 +280,11 @@ class ScenarioBuilder { ) } - status(status = 200, inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, compare: Comparison = "status") { + status( + status = 200, + inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, + compare: Comparison = "status", + ) { return this.done(compare, (ctx, result) => Effect.gen(function* () { if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) @@ -287,19 +295,20 @@ class ScenarioBuilder { /** Assert JSON status/content-type plus an optional synchronous body check. */ json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { - return this.jsonEffect( - status, - inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, - compare, - ) + return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) } /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ - jsonEffect(status = 200, inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, compare: Comparison = "json") { + jsonEffect( + status = 200, + inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, + compare: Comparison = "json", + ) { return this.done(compare, (ctx, result) => Effect.gen(function* () { if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - if (!looksJson(result)) throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (!looksJson(result)) + throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) if (inspect) yield* inspect(result.body, ctx) }), ) @@ -321,7 +330,10 @@ class ScenarioBuilder { return builder } - private done(compare: Comparison, expect: (ctx: SeededContext, result: CallResult) => Effect.Effect): ActiveScenario { + private done( + compare: Comparison, + expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, + ): ActiveScenario { const state = this.state return { kind: "active", @@ -357,52 +369,80 @@ const pending = (method: Method, path: string, name: string, reason: string): To }) function route(template: string, params: Record) { - return Object.entries(params).reduce((next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), template) + return Object.entries(params).reduce( + (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), + template, + ) } const scenarios: Scenario[] = [ - http.get("/global/health", "global.health").global().json(200, (body) => { - object(body) - check(body.healthy === true, "server should report healthy") - }), + http + .get("/global/health", "global.health") + .global() + .json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), http .get("/global/event", "global.event") .global() .stream() - .status(200, (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") - check(result.text.includes("server.connected"), "global event should emit initial connection event") - }), - "status"), + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status", + ), http.get("/global/config", "global.config.get").global().json(), http .patch("/global/config", "global.config.update") .global() .seeded(() => Effect.promise(() => - Bun.write(path.join(exerciseConfigDirectory, "opencode.jsonc"), JSON.stringify({ username: "httpapi-global" }, null, 2)), + Bun.write( + path.join(exerciseConfigDirectory, "opencode.jsonc"), + JSON.stringify({ username: "httpapi-global" }, null, 2), + ), ), ) .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) - .jsonEffect(200, (body) => - Effect.gen(function* () { - object(body) - check(body.username === "httpapi-global", "global config update should return patched config") - const text = yield* Effect.promise(() => Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text()) - check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") - }), - "status"), - http.post("/global/dispose", "global.dispose").global().mutating().json(200, (body) => { - check(body === true, "global dispose should return true") - }, "status"), + .jsonEffect( + 200, + (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => + Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), + ) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status", + ), + http + .post("/global/dispose", "global.dispose") + .global() + .mutating() + .json( + 200, + (body) => { + check(body === true, "global dispose should return true") + }, + "status", + ), http.get("/path", "path.get").json(200, (body, ctx) => { object(body) check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") }), http.get("/vcs", "vcs.get").json(), - http.get("/vcs/diff", "vcs.diff").at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })).json(200, array), + http + .get("/vcs/diff", "vcs.diff") + .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) + .json(200, array), http.get("/command", "command.list").json(200, array, "status"), http.get("/agent", "app.agents").json(200, array, "status"), http.get("/skill", "app.skills").json(200, array, "status"), @@ -413,20 +453,28 @@ const scenarios: Scenario[] = [ .patch("/config", "config.update") .mutating() .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) - .json(200, (body) => { - object(body) - check(body.username === "httpapi-local", "local config update should return patched config") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, + "status", + ), http .patch("/config", "config.update.invalid") .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) .status(400), http.get("/config/providers", "config.providers").json(), http.get("/project", "project.list").json(200, array, "status"), - http.get("/project/current", "project.current").json(200, (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "current project should resolve from scenario directory") - }, "status"), + http.get("/project/current", "project.current").json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), http .patch("/project/{projectID}", "project.update") .mutating() @@ -436,55 +484,93 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { name: "HTTP API Project", commands: { start: "bun --version" } }, })) - .json(200, (body) => { - object(body) - check(body.name === "HTTP API Project", "project update should return patched name") - check(isRecord(body.commands) && body.commands.start === "bun --version", "project update should return patched command") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check( + isRecord(body.commands) && body.commands.start === "bun --version", + "project update should return patched command", + ) + }, + "status", + ), http .post("/project/git/init", "project.initGit") .mutating() .inProject({ git: false }) - .json(200, (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "git init should return current project") - check(body.vcs === "git", "git init should mark the project as git-backed") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, + "status", + ), http.get("/provider", "provider.list").json(), http.get("/provider/auth", "provider.auth").json(), http .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") - .at((ctx) => ({ path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) .status(400), http .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") - .at((ctx) => ({ path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) .status(400), http.get("/permission", "permission.list").json(200, array), http .post("/permission/{requestID}/reply", "permission.reply.invalid") - .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "bad" } })) + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "bad" }, + })) .status(400), http .post("/permission/{requestID}/reply", "permission.reply") - .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "once" } })) + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "once" }, + })) .json(200, (body) => { check(body === true, "permission reply should return true even when request is no longer pending") }), http.get("/question", "question.list").json(200, array), http .post("/question/{requestID}/reply", "question.reply.invalid") - .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: "Yes" } })) + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: "Yes" }, + })) .status(400), http .post("/question/{requestID}/reply", "question.reply") - .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: [["Yes"]] } })) + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: [["Yes"]] }, + })) .json(200, (body) => { check(body === true, "question reply should return true even when request is no longer pending") }), http .post("/question/{requestID}/reject", "question.reject") - .at((ctx) => ({ path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), + headers: ctx.headers(), + })) .json(200, (body) => { check(body === true, "question reject should return true even when request is no longer pending") }), @@ -517,7 +603,10 @@ const scenarios: Scenario[] = [ http .get("/find/file", "find.files") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, headers: ctx.headers() })) + .at((ctx) => ({ + path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers: ctx.headers(), + })) .json(200, array), http .get("/find/symbol", "find.symbols") @@ -527,12 +616,15 @@ const scenarios: Scenario[] = [ http .get("/event", "event.stream") .stream() - .status(200, (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") - check(result.text.includes("server.connected"), "event should emit initial connection event") - }), - "status"), + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status", + ), http.get("/mcp", "mcp.status").json(), http .post("/mcp", "mcp.add") @@ -542,22 +634,34 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, })) - .json(200, (body) => { - object(body) - object(body["httpapi-disabled"]) - check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") - }, "status"), + .json( + 200, + (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, + "status", + ), http .post("/mcp", "mcp.add.invalid") - .at((ctx) => ({ path: "/mcp", headers: ctx.headers(), body: { name: "httpapi-invalid", config: { type: "invalid" } } })) + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-invalid", config: { type: "invalid" } }, + })) .status(400), http .post("/mcp/{name}/auth", "mcp.auth.start") .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(400, (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth response should include error") - }, "status"), + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, + "status", + ), http .delete("/mcp/{name}/auth", "mcp.auth.remove") .mutating() @@ -568,14 +672,25 @@ const scenarios: Scenario[] = [ }), http .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") - .at((ctx) => ({ path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(400, (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") - }, "status"), + .at((ctx) => ({ + path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), + headers: ctx.headers(), + })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, + "status", + ), http .post("/mcp/{name}/auth/callback", "mcp.auth.callback") - .at((ctx) => ({ path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), headers: ctx.headers(), body: { code: 1 } })) + .at((ctx) => ({ + path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), + headers: ctx.headers(), + body: { code: 1 }, + })) .status(400), http .post("/mcp/{name}/connect", "mcp.connect") @@ -597,12 +712,16 @@ const scenarios: Scenario[] = [ .post("/pty", "pty.create") .mutating() .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) - .json(200, (body, ctx) => { - object(body) - check(body.title === "HTTP API PTY", "PTY create should return requested title") - check(body.command === "/bin/sh", "PTY create should use controlled shell command") - check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, + "status", + ), http .post("/pty", "pty.create.invalid") .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) @@ -635,7 +754,11 @@ const scenarios: Scenario[] = [ http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), http .post("/experimental/console/switch", "experimental.console.switchOrg") - .at((ctx) => ({ path: "/experimental/console/switch", headers: ctx.headers(), body: { accountID: "httpapi-account", orgID: "httpapi-org" } })) + .at((ctx) => ({ + path: "/experimental/console/switch", + headers: ctx.headers(), + body: { accountID: "httpapi-account", orgID: "httpapi-org" }, + })) .status(400, undefined, "none"), http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), @@ -647,15 +770,25 @@ const scenarios: Scenario[] = [ http .delete("/experimental/workspace/{id}", "experimental.workspace.remove") .mutating() - .at((ctx) => ({ path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + })) .status(200), http .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") - .at((ctx) => ({ path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), headers: ctx.headers(), body: {} })) + .at((ctx) => ({ + path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + body: {}, + })) .status(400), http .get("/experimental/tool", "tool.list") - .at((ctx) => ({ path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, headers: ctx.headers() })) + .at((ctx) => ({ + path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, + headers: ctx.headers(), + })) .json(200, array, "status"), http.get("/experimental/tool/ids", "tool.ids").json(200, array), http.get("/experimental/worktree", "worktree.list").json(200, array), @@ -663,13 +796,16 @@ const scenarios: Scenario[] = [ .post("/experimental/worktree", "worktree.create") .mutating() .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(typeof body.directory === "string", "created worktree should include directory") - yield* ctx.worktreeRemove(body.directory) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status", + ), http .post("/experimental/worktree", "worktree.create.invalid") .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) @@ -686,7 +822,11 @@ const scenarios: Scenario[] = [ .post("/experimental/worktree/reset", "worktree.reset") .mutating() .seeded((ctx) => ctx.worktree({ name: "api-reset" })) - .at((ctx) => ({ path: "/experimental/worktree/reset", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .at((ctx) => ({ + path: "/experimental/worktree/reset", + headers: ctx.headers(), + body: { directory: ctx.state.directory }, + })) .jsonEffect(200, (body, ctx) => Effect.gen(function* () { check(body === true, "worktree reset should return true") @@ -695,17 +835,27 @@ const scenarios: Scenario[] = [ ), http.get("/experimental/session", "experimental.session.list").json(200, array), http.get("/experimental/resource", "experimental.resource.list").json(), - http.post("/sync/history", "sync.history.list").at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })).json(200, array), + http + .post("/sync/history", "sync.history.list") + .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) + .json(200, array), http .post("/sync/replay", "sync.replay") .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) .status(400), - http.post("/sync/start", "sync.start").mutating().preserveDatabase().json(200, (body) => { - check(body === true, "sync start should return true when no workspace sessions exist") - }), - http.post("/instance/dispose", "instance.dispose").mutating().json(200, (body) => { - check(body === true, "instance dispose should return true") - }), + http + .post("/sync/start", "sync.start") + .mutating() + .preserveDatabase() + .json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http + .post("/instance/dispose", "instance.dispose") + .mutating() + .json(200, (body) => { + check(body === true, "instance dispose should return true") + }), http .post("/log", "app.log") .global() @@ -730,7 +880,10 @@ const scenarios: Scenario[] = [ .global() .seeded(() => Effect.promise(() => - Bun.write(path.join(exerciseDataDirectory, "auth.json"), JSON.stringify({ test: { type: "api", key: "remove-me" } })), + Bun.write( + path.join(exerciseDataDirectory, "auth.json"), + JSON.stringify({ test: { type: "api", key: "remove-me" } }), + ), ), ) .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) @@ -748,7 +901,10 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) .json(200, (body, ctx) => { array(body) - check(body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), "seeded session should be listed") + check( + body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), + "seeded session should be listed", + ) }), http .get("/session/status", "session.status") @@ -758,11 +914,15 @@ const scenarios: Scenario[] = [ .post("/session", "session.create") .mutating() .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) - .json(200, (body, ctx) => { - object(body) - check(body.title === "Created session", "created session should use requested title") - check(body.directory === ctx.directory, "created session should use scenario directory") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, + "status", + ), http .get("/session/{sessionID}", "session.get") .seeded((ctx) => ctx.session({ title: "Get me" })) @@ -774,21 +934,36 @@ const scenarios: Scenario[] = [ }), http .get("/session/{sessionID}", "session.get.missing") - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) .status(404), http .patch("/session/{sessionID}", "session.update") .mutating() .seeded((ctx) => ctx.session({ title: "Before rename" })) - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers(), body: { title: "After rename" } })) - .json(200, (body) => { - object(body) - check(body.title === "After rename", "updated session should use new title") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { title: "After rename" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, + "status", + ), http .patch("/session/{sessionID}", "session.update.invalid") .mutating() - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), body: { title: 1 } })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: { title: 1 }, + })) .status(400), http .delete("/session/{sessionID}", "session.delete") @@ -810,10 +985,16 @@ const scenarios: Scenario[] = [ return { parent, child } }), ) - .at((ctx) => ({ path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), + headers: ctx.headers(), + })) .json(200, (body, ctx) => { array(body) - check(body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), "children should include seeded child") + check( + body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), + "children should include seeded child", + ) }), http .get("/session/{sessionID}/todo", "session.todo") @@ -825,7 +1006,10 @@ const scenarios: Scenario[] = [ return { session, todos } }), ) - .at((ctx) => ({ path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + })) .json(200, (body, ctx) => { check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") }), @@ -861,7 +1045,10 @@ const scenarios: Scenario[] = [ .json(200, (body, ctx) => { object(body) check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), "message should include seeded part") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), + "message should include seeded part", + ) }), http .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") @@ -882,10 +1069,14 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { ...ctx.state.message.part, text: "after" }, })) - .json(200, (body) => { - object(body) - check(body.type === "text" && body.text === "after", "updated part should be returned") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, + "status", + ), http .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") .mutating() @@ -938,11 +1129,19 @@ const scenarios: Scenario[] = [ .post("/session/{sessionID}/fork", "session.fork") .mutating() .seeded((ctx) => ctx.session({ title: "Fork source" })) - .at((ctx) => ({ path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), headers: ctx.headers(), body: {} })) - .json(200, (body) => { - object(body) - check(typeof body.id === "string", "fork should return a session") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json( + 200, + (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, + "status", + ), http .post("/session/{sessionID}/abort", "session.abort") .mutating() @@ -953,7 +1152,10 @@ const scenarios: Scenario[] = [ }), http .post("/session/{sessionID}/abort", "session.abort.missing") - .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) .json(200, (body) => { check(body === true, "missing session abort should remain a no-op success") }), @@ -1002,14 +1204,20 @@ const scenarios: Scenario[] = [ parts: [{ type: "text", text: "hello llm" }], }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), "assistant message should use fake LLM text") - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), + "assistant message should use fake LLM text", + ) + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/prompt_async", "session.prompt_async") .preserveDatabase() @@ -1053,13 +1261,16 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { command: "init", arguments: "", model: "test/test-model" }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/shell", "session.shell") .preserveDatabase() @@ -1070,11 +1281,18 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, })) - .json(200, (body) => { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), "shell should return a tool part") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), + "shell should return a tool part", + ) + }, + "status", + ), http .post("/session/{sessionID}/summarize", "session.summarize") .preserveDatabase() @@ -1122,17 +1340,20 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { providerID: "test", modelID: "test-model", auto: false }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "summarize should return true") - const messages = yield* ctx.messages(ctx.state.id) - check( - messages.some((message) => message.info.role === "assistant" && message.info.summary === true), - "summarize should create a summary assistant message", - ) - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/revert", "session.revert") .mutating() @@ -1148,25 +1369,42 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { messageID: ctx.state.message.info.id }, })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.session.id, "revert should return the session") - check(isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, "revert should record reverted message") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check( + isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, + "revert should record reverted message", + ) + }, + "status", + ), http .post("/session/{sessionID}/unrevert", "session.unrevert") .mutating() .seeded((ctx) => ctx.session({ title: "Unrevert session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unrevert should return the session") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, + "status", + ), http .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) .at((ctx) => ({ - path: route("/session/{sessionID}/permissions/{permissionID}", { sessionID: ctx.state.id, permissionID: "per_httpapi_deprecated" }), + path: route("/session/{sessionID}/permissions/{permissionID}", { + sessionID: ctx.state.id, + permissionID: "per_httpapi_deprecated", + }), headers: ctx.headers(), body: { response: "once" }, })) @@ -1178,19 +1416,27 @@ const scenarios: Scenario[] = [ .mutating() .seeded((ctx) => ctx.session({ title: "Share session" })) .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "share should return the session") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, + "status", + ), http .delete("/session/{sessionID}/share", "session.unshare") .mutating() .seeded((ctx) => ctx.session({ title: "Unshare session" })) .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unshare should return the session") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, + "status", + ), http .post("/tui/append-prompt", "tui.appendPrompt") .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) @@ -1238,13 +1484,21 @@ const scenarios: Scenario[] = [ .get("/tui/control/next", "tui.control.next") .mutating() .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) - .json(200, (body) => { - object(body) - check(body.path === "/tui/exercise", "control next should return queued path") - object(body.body) - check(body.body.text === "queued", "control next should return queued body") - }, "status"), - http.post("/global/upgrade", "global.upgrade").global().at(() => ({ path: "/global/upgrade", body: { target: 1 } })).status(400), + .json( + 200, + (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, + "status", + ), + http + .post("/global/upgrade", "global.upgrade") + .global() + .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) + .status(400), ] const main = Effect.gen(function* () { @@ -1259,12 +1513,18 @@ const main = Effect.gen(function* () { printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) - const results = options.mode === "coverage" ? selected.map(coverageResult) : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + const results = + options.mode === "coverage" + ? selected.map(coverageResult) + : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) printResults(results, missing, extra) - if (results.some((result) => result.status === "fail")) return yield* Effect.fail(new Error("one or more scenarios failed")) - if (options.failOnSkip && results.some((result) => result.status === "skip")) return yield* Effect.fail(new Error("one or more scenarios are skipped")) - if (options.failOnMissing && missing.length > 0) return yield* Effect.fail(new Error("one or more routes have no scenario")) + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) }) function runScenario(options: Options) { @@ -1322,102 +1582,107 @@ function withContext(scenario: ActiveScenario, use: (ctx: SeededContext Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), ).pipe( - Effect.flatMap((context) => Effect.gen(function* () { - const modules = yield* Effect.promise(() => runtime()) - const path = context.dir?.path - const instance = path - ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - Effect.catchCause((cause) => - Effect.sleep("100 millis").pipe( - Effect.andThen( - modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), + Effect.flatMap((context) => + Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), ), + Effect.catchCause(() => Effect.failCause(cause)), ), - Effect.catchCause(() => Effect.failCause(cause)), - ), - ), - ) - : undefined - const run = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) - const directory = () => { - if (!context.dir?.path) throw new Error("scenario needs a project directory") - return context.dir.path - } - const llm = () => { - if (!context.llm) throw new Error("scenario needs fake LLM") - return context.llm - } - const base: ScenarioContext = { - directory: context.dir?.path, - headers: (extra) => ({ ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), ...extra }), - file: (name, content) => - Effect.promise(() => { - return Bun.write(`${directory()}/${name}`, content) - }).pipe(Effect.asVoid), - session: (input) => - run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), - sessionGet: (sessionID) => - run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), - ), - project: () => - Effect.sync(() => { - if (!instance) throw new Error("scenario needs a project directory") - return instance.project - }), - message: (sessionID, input) => - Effect.gen(function* () { - const info: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: "build", - model: { - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), - }, - } - const part: MessageV2.TextPart = { - id: PartID.ascending(), - sessionID, - messageID: info.id, - type: "text", - text: input?.text ?? "hello", - } - yield* run( - modules.Session.Service.use((svc) => - Effect.gen(function* () { - yield* svc.updateMessage(info) - yield* svc.updatePart(part) - }), ), ) - return { info, part } + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ + ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), + ...extra, }), - messages: (sessionID) => - run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), - todos: (sessionID, todos) => - run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), - worktree: (input) => - run(modules.Worktree.Service.use((svc) => svc.create(input))), - worktreeRemove: (directory) => - run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), - llmText: (value) => Effect.suspend(() => llm().text(value)), - llmWait: (count) => Effect.suspend(() => llm().wait(count)), - tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), - } - const state = yield* scenario.seed(base) - return yield* use({ ...base, state }) - }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void))), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), + ), Effect.ensuring(scenario.reset ? resetState : Effect.void), ) } -function projectOptions(project: ProjectOptions, llmUrl: string | undefined): { git?: boolean; config?: Partial } { +function projectOptions( + project: ProjectOptions, + llmUrl: string | undefined, +): { git?: boolean; config?: Partial } { if (!project.llm || !llmUrl) return { git: project.git, config: project.config } const fake = fakeLlmConfig(llmUrl) return { @@ -1475,7 +1740,9 @@ function controlledPtyInput(title: string | undefined) { } function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { - return Effect.promise(async () => capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture)) + return Effect.promise(async () => + capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), + ) } const appCache: Partial> = {} @@ -1494,13 +1761,20 @@ function app(modules: Runtime, backend: Backend) { const handler = HttpRouter.toWebHandler( modules.ExperimentalHttpApiServer.routes.pipe( - Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }))), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), + ), + ), ), { disableLogger: true }, ).handler return (appCache.effect = { request(input: string | URL | Request, init?: RequestInit) { - return handler(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), modules.ExperimentalHttpApiServer.context) + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + modules.ExperimentalHttpApiServer.context, + ) }, }) } @@ -1545,16 +1819,23 @@ async function captureStream(response: Response) { const cleanupExercisePaths = Effect.promise(async () => { const fs = await import("fs/promises") if (!preserveExerciseDatabase) { - await Promise.all([exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => fs.rm(file, { force: true }).catch(() => undefined))) + await Promise.all( + [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => + fs.rm(file, { force: true }).catch(() => undefined), + ), + ) } - if (!preserveExerciseGlobalRoot) await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) + if (!preserveExerciseGlobalRoot) + await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) }) function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { return Effect.sync(() => { - if (effect.status !== legacy.status) throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (effect.status !== legacy.status) + throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) if (scenario.compare === "status") return - if (stable(effect.body) !== stable(legacy.body)) throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + if (stable(effect.body) !== stable(legacy.body)) + throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) }) } @@ -1570,7 +1851,9 @@ const resetState = Effect.promise(async () => { function routeKeys(spec: OpenApiSpec) { return Object.entries(spec.paths ?? {}) - .flatMap(([path, item]) => OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .flatMap(([path, item]) => + OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) .sort() } @@ -1602,10 +1885,21 @@ function option(args: string[], name: string) { function matches(options: Options, scenario: Scenario) { if (!options.include) return true - return scenario.name.includes(options.include) || scenario.path.includes(options.include) || scenario.method.includes(options.include.toUpperCase()) + return ( + scenario.name.includes(options.include) || + scenario.path.includes(options.include) || + scenario.method.includes(options.include.toUpperCase()) + ) } -function printHeader(options: Options, effectRoutes: string[], honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[]) { +function printHeader( + options: Options, + effectRoutes: string[], + honoRoutes: string[], + selected: Scenario[], + missing: string[], + extra: Scenario[], +) { console.log(`${color.cyan}HttpApi exerciser${color.reset}`) console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) @@ -1618,14 +1912,20 @@ function printHeader(options: Options, effectRoutes: string[], honoRoutes: strin function printResults(results: Result[], missing: string[], extra: Scenario[]) { for (const result of results) { if (result.status === "pass") { - console.log(`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log( + `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) continue } if (result.status === "skip") { - console.log(`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`) + console.log( + `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, + ) continue } - console.log(`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log( + `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) console.log(`${color.red}${indent(result.message)}${color.reset}`) } if (missing.length > 0) { @@ -1634,7 +1934,8 @@ function printResults(results: Result[], missing: string[], extra: Scenario[]) { } if (extra.length > 0) { console.log("\nExtra scenarios") - for (const scenario of extra) console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + for (const scenario of extra) + console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) } console.log( `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, @@ -1661,7 +1962,11 @@ function stable(value: unknown): string { function sort(value: unknown): unknown { if (Array.isArray(value)) return value.map(sort) if (!value || typeof value !== "object") return value - return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sort(item)])) + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sort(item)]), + ) } function array(value: unknown): asserts value is unknown[] { diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 2fbf5ca11b..e454fa7e42 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -61,7 +61,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } instance.only = ( @@ -71,7 +75,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test.only(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test.only( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } instance.skip = ( @@ -81,7 +89,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test.skip(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test.skip( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } return { effect, live, instance } diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 461fb88f26..9e577ec3cd 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -54,11 +54,36 @@ const waitForPending = (count: number) => return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) }) -it.instance("ask - remains pending until answered", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ +it.instance( + "ask - remains pending until answered", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) + +it.instance( + "ask - adds to pending list", + () => + Effect.gen(function* () { + const questions = [ { question: "What would you like to do?", header: "Action", @@ -67,81 +92,29 @@ it.instance("ask - remains pending until answered", () => { label: "Option 2", description: "Second option" }, ], }, - ], - }).pipe(Effect.forkScoped) + ] - expect(yield* waitForPending(1)).toHaveLength(1) - yield* rejectAll - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - }), - { git: true }, -) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) -it.instance("ask - adds to pending list", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) - - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) - expect(pending[0].questions).toEqual(questions) - yield* rejectAll - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - }), + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + expect(pending[0].questions).toEqual(questions) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), { git: true }, ) // reply tests -it.instance("reply - resolves the pending ask with answers", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) - - const pending = yield* waitForPending(1) - const requestID = pending[0].id - - yield* replyEffect({ - requestID, - answers: [["Option 1"]], - }) - - expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) - }), - { git: true }, -) - -it.instance("reply - removes from pending list", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ +it.instance( + "reply - resolves the pending ask with answers", + () => + Effect.gen(function* () { + const questions = [ { question: "What would you like to do?", header: "Action", @@ -150,170 +123,219 @@ it.instance("reply - removes from pending list", () => { label: "Option 2", description: "Second option" }, ], }, - ], - }).pipe(Effect.forkScoped) + ] - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) - yield* replyEffect({ - requestID: pending[0].id, - answers: [["Option 1"]], - }) - yield* Fiber.join(fiber) + const pending = yield* waitForPending(1) + const requestID = pending[0].id - const after = yield* listEffect - expect(after.length).toBe(0) - }), + yield* replyEffect({ + requestID, + answers: [["Option 1"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), { git: true }, ) -it.instance("reply - does nothing for unknown requestID", () => - replyEffect({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }), +it.instance( + "reply - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Option 1"]], + }) + yield* Fiber.join(fiber) + + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) + +it.instance( + "reply - does nothing for unknown requestID", + () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), { git: true }, ) // reject tests -it.instance("reject - throws RejectedError", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "reject - throws RejectedError", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) - yield* rejectEffect(pending[0].id) + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) - const exit = yield* Fiber.await(fiber) - expect(exit._tag).toBe("Failure") - if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") - }), + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), { git: true }, ) -it.instance("reject - removes from pending list", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "reject - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - yield* rejectEffect(pending[0].id) - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - const after = yield* listEffect - expect(after.length).toBe(0) - }), + const after = yield* listEffect + expect(after.length).toBe(0) + }), { git: true }, ) -it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true }) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { + git: true, +}) // multiple questions tests -it.instance("ask - handles multiple questions", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Build", description: "Build the project" }, - { label: "Test", description: "Run tests" }, - ], - }, - { - question: "Which environment?", - header: "Env", - options: [ - { label: "Dev", description: "Development" }, - { label: "Prod", description: "Production" }, - ], - }, - ] +it.instance( + "ask - handles multiple questions", + () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Build", description: "Build the project" }, + { label: "Test", description: "Run tests" }, + ], + }, + { + question: "Which environment?", + header: "Env", + options: [ + { label: "Dev", description: "Development" }, + { label: "Prod", description: "Production" }, + ], + }, + ] - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) + const pending = yield* waitForPending(1) - yield* replyEffect({ - requestID: pending[0].id, - answers: [["Build"], ["Dev"]], - }) + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Build"], ["Dev"]], + }) - expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) - }), + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), { git: true }, ) // list tests -it.instance("list - returns all pending requests", () => - Effect.gen(function* () { - const fiber1 = yield* askEffect({ - sessionID: SessionID.make("ses_test1"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "list - returns all pending requests", + () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_test1"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(Effect.forkScoped) - const fiber2 = yield* askEffect({ - sessionID: SessionID.make("ses_test2"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }).pipe(Effect.forkScoped) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_test2"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(2) - expect(pending.length).toBe(2) - yield* rejectAll - expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") - expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") - }), + const pending = yield* waitForPending(2) + expect(pending.length).toBe(2) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), { git: true }, ) -it.instance("list - returns empty when no pending", () => - Effect.gen(function* () { - const pending = yield* listEffect - expect(pending.length).toBe(0) - }), +it.instance( + "list - returns empty when no pending", + () => + Effect.gen(function* () { + const pending = yield* listEffect + expect(pending.length).toBe(0) + }), { git: true }, ) From 7d91d3b1ed3d5385d9f2e5a6976d6ac32f98cf18 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:39:20 -0400 Subject: [PATCH 03/19] Normalize instance lifecycle wiring (#25501) --- packages/opencode/src/cli/bootstrap.ts | 3 +- packages/opencode/src/cli/cmd/agent.ts | 5 +- packages/opencode/src/cli/cmd/github.ts | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 13 +- packages/opencode/src/cli/cmd/providers.ts | 4 +- .../src/cli/cmd/tui/plugin/runtime.ts | 6 +- packages/opencode/src/cli/cmd/tui/worker.ts | 4 +- packages/opencode/src/effect/app-runtime.ts | 7 +- .../opencode/src/project/instance-layer.ts | 11 + .../opencode/src/project/instance-runtime.ts | 31 +- .../opencode/src/project/instance-store.ts | 27 +- packages/opencode/src/project/instance.ts | 7 - .../opencode/src/project/with-instance.ts | 10 + .../src/server/routes/instance/config.ts | 37 +- .../server/routes/instance/httpapi/server.ts | 6 +- .../src/server/routes/instance/middleware.ts | 4 +- packages/opencode/src/server/workspace.ts | 4 +- packages/opencode/src/worktree/index.ts | 27 +- .../test/acp/event-subscription.test.ts | 21 +- packages/opencode/test/agent/agent.test.ts | 77 ++-- .../agent/plugin-agent-regression.test.ts | 3 +- .../opencode/test/bus/bus-integration.test.ts | 3 +- packages/opencode/test/bus/bus.test.ts | 3 +- packages/opencode/test/config/config.test.ts | 105 +++--- .../test/control-plane/workspace.test.ts | 3 +- packages/opencode/test/file/fsmonitor.test.ts | 5 +- packages/opencode/test/file/index.test.ts | 109 +++--- .../opencode/test/file/path-traversal.test.ts | 23 +- packages/opencode/test/file/watcher.test.ts | 5 +- packages/opencode/test/fixture/fixture.ts | 3 +- packages/opencode/test/lsp/client.test.ts | 25 +- packages/opencode/test/mcp/headers.test.ts | 7 +- packages/opencode/test/mcp/lifecycle.test.ts | 3 +- .../test/mcp/oauth-auto-connect.test.ts | 9 +- .../opencode/test/mcp/oauth-browser.test.ts | 7 +- .../opencode/test/permission-task.test.ts | 13 +- .../opencode/test/permission/next.test.ts | 3 +- .../instance-bootstrap-regression.test.ts | 3 +- .../opencode/test/project/instance.test.ts | 124 +++---- packages/opencode/test/project/vcs.test.ts | 5 +- .../opencode/test/project/worktree.test.ts | 5 +- .../test/provider/amazon-bedrock.test.ts | 61 ++- .../opencode/test/provider/gitlab-duo.test.ts | 27 +- .../opencode/test/provider/provider.test.ts | 347 +++++++----------- .../test/pty/pty-output-isolation.test.ts | 7 +- .../opencode/test/pty/pty-session.test.ts | 5 +- packages/opencode/test/pty/pty-shell.test.ts | 7 +- .../opencode/test/question/question.test.ts | 3 +- .../test/server/global-session-list.test.ts | 13 +- .../test/server/httpapi-experimental.test.ts | 5 +- .../server/httpapi-instance-context.test.ts | 4 +- .../opencode/test/server/httpapi-mcp.test.ts | 3 +- .../test/server/httpapi-provider.test.ts | 3 +- .../opencode/test/server/httpapi-sdk.test.ts | 3 +- .../test/server/httpapi-session.test.ts | 5 +- .../opencode/test/server/httpapi-sync.test.ts | 3 +- .../test/server/session-actions.test.ts | 3 +- .../opencode/test/server/session-list.test.ts | 41 ++- .../test/server/session-messages.test.ts | 9 +- .../test/server/session-select.test.ts | 7 +- .../opencode/test/session/compaction.test.ts | 39 +- packages/opencode/test/session/llm.test.ts | 17 +- .../test/session/messages-pagination.test.ts | 85 ++--- .../opencode/test/session/session.test.ts | 9 +- .../structured-output-integration.test.ts | 3 +- .../opencode/test/snapshot/snapshot.test.ts | 115 +++--- .../opencode/test/tool/apply_patch.test.ts | 49 +-- packages/opencode/test/tool/edit.test.ts | 37 +- .../test/tool/external-directory.test.ts | 15 +- packages/opencode/test/tool/shell.test.ts | 83 ++--- packages/opencode/test/tool/webfetch.test.ts | 7 +- 71 files changed, 852 insertions(+), 936 deletions(-) create mode 100644 packages/opencode/src/project/instance-layer.ts create mode 100644 packages/opencode/src/project/with-instance.ts diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 81a085d689..fa39ecb177 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,8 +1,9 @@ import { Instance } from "../project/instance" import { InstanceRuntime } from "../project/instance-runtime" +import { WithInstance } from "../project/with-instance" export async function bootstrap(directory: string, cb: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { try { diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a1a440eaa1..11a6c7f430 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -10,6 +10,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import { EOL } from "os" import type { Argv } from "yargs" @@ -61,7 +62,7 @@ const AgentCreateCommand = cmd({ describe: "model to use in the format of provider/model", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { const cliPath = args.path @@ -236,7 +237,7 @@ const AgentListCommand = cmd({ command: "list", describe: "list all available agents", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index a75dc31634..e707526dfe 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -20,6 +20,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { ModelsDev } from "@/provider/models" import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { bootstrap } from "../bootstrap" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" @@ -203,7 +204,7 @@ export const GithubInstallCommand = cmd({ command: "install", describe: "install the GitHub agent", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { { diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d244549fff..e4d7bd9224 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -114,7 +115,7 @@ export const McpListCommand = cmd({ aliases: ["ls"], describe: "list MCP servers and their status", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -186,7 +187,7 @@ export const McpAuthCommand = cmd({ }) .command(McpAuthListCommand), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -318,7 +319,7 @@ export const McpAuthListCommand = cmd({ aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -357,7 +358,7 @@ export const McpLogoutCommand = cmd({ type: "string", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -448,7 +449,7 @@ export const McpAddCommand = cmd({ command: "add", describe: "add an MCP server", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -618,7 +619,7 @@ export const McpDebugCommand = cmd({ demandOption: true, }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index c383e79ce8..ca64526182 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -13,7 +13,7 @@ import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { text } from "node:stream/consumers" @@ -303,7 +303,7 @@ export const ProvidersLoginCommand = cmd({ type: "string", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 2ef5333245..73193d142e 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import * as Log from "@opencode-ai/core/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { readPackageThemes, readPluginId, @@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { state.pending.delete(spec) return true } - const ready = await Instance.provide({ + const ready = await WithInstance.provide({ directory: state.directory, fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), }).catch((error) => { @@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) { } runtime = next try { - await Instance.provide({ + await WithInstance.provide({ directory: cwd, fn: async () => { const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e4fbeb2fbc..775f321bb5 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -1,8 +1,8 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "@/project/instance" import { InstanceRuntime } from "@/project/instance-runtime" +import { WithInstance } from "@/project/with-instance" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -77,7 +77,7 @@ export const rpc = { return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { - await Instance.provide({ + await WithInstance.provide({ directory: input.directory, fn: async () => { await upgrade().catch(() => {}) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 901738646c..e8c8025ea3 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -40,7 +40,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceRuntime } from "@/project/instance-runtime" +import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -93,17 +93,16 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, - InstanceRuntime.layer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Pty.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, -).pipe(Layer.provideMerge(Observability.layer)) +).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts new file mode 100644 index 0000000000..a7e2bfcb7b --- /dev/null +++ b/packages/opencode/src/project/instance-layer.ts @@ -0,0 +1,11 @@ +import { Effect, Layer } from "effect" +import { InstanceStore } from "./instance-store" + +export const layer = Layer.unwrap( + Effect.promise(async () => { + const { InstanceBootstrap } = await import("./bootstrap") + return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) + }), +) + +export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts index a30bf56107..c8803847a0 100644 --- a/packages/opencode/src/project/instance-runtime.ts +++ b/packages/opencode/src/project/instance-runtime.ts @@ -1,27 +1,16 @@ -import { makeRuntime } from "@/effect/run-service" +import { AppRuntime } from "@/effect/app-runtime" import { type InstanceContext } from "./instance-context" import { InstanceStore, type LoadInput } from "./instance-store" -import { Effect, Layer } from "effect" -// Production InstanceStore wiring plus a bridge for Promise/ALS callers that -// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself -// low-level while still giving legacy Hono and CLI paths the production -// bootstrap implementation. Delete the Promise helpers once those callers are -// migrated to Effect boundaries that provide InstanceStore directly. -// Keep the bootstrap implementation import lazy: Instance is imported broadly, -// and importing the app bootstrap graph at module load can trigger ESM cycles. -export const layer = Layer.unwrap( - Effect.promise(async () => { - const { InstanceBootstrap } = await import("./bootstrap") - return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) - }), -) +// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service. +// Delete this module once those callers are migrated to Effect boundaries that +// provide InstanceStore directly. -const runtime = makeRuntime(InstanceStore.Service, layer) - -export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input)) -export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) -export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) -export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) +export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input))) +export const disposeInstance = (ctx: InstanceContext) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx))) +export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())) +export const reloadInstance = (input: LoadInput) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input))) export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 41adcbc7cf..4fa1c3dfff 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -8,26 +8,18 @@ import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" import * as Project from "./project" -export interface LoadInput { +export interface LoadInput { directory: string - /** - * Additional setup to run after the default InstanceBootstrap. - * Mainly used by tests for env-var setup or file writes that need the instance ALS context. - */ - init?: Effect.Effect worktree?: string project?: Project.Info } export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect - readonly provide: ( - input: LoadInput, - effect: Effect.Effect, - ) => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -44,7 +36,7 @@ export const layer: Layer.Layer() - const boot = (input: LoadInput & { directory: string }) => + const boot = (input: LoadInput & { directory: string }) => Effect.gen(function* () { const ctx: InstanceContext = input.project && input.worktree @@ -61,7 +53,6 @@ export const layer: Layer.Layer(directory: string, input: LoadInput, entry: Entry) => + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => Effect.gen(function* () { const exit = yield* Effect.exit(boot({ ...input, directory })) if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) @@ -108,7 +99,7 @@ export const layer: Layer.Layer(input: LoadInput): Effect.Effect => { + const load = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -126,7 +117,7 @@ export const layer: Layer.Layer(input: LoadInput): Effect.Effect => { + const reload = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -180,7 +171,7 @@ export const layer: Layer.Layer(input: LoadInput, effect: Effect.Effect): Effect.Effect => + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 81977affc3..a54291cf0c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,15 +1,8 @@ -import { Effect } from "effect" import { context, type InstanceContext } from "./instance-context" -import { InstanceRuntime } from "./instance-runtime" export type { InstanceContext } from "./instance-context" -export type { LoadInput } from "./instance-store" export const Instance = { - async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init }) - return context.provide(ctx, async () => input.fn()) - }, get current() { return context.use() }, diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts new file mode 100644 index 0000000000..b5b0e7c079 --- /dev/null +++ b/packages/opencode/src/project/with-instance.ts @@ -0,0 +1,10 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { context } from "./instance-context" +import { InstanceStore } from "./instance-store" + +export async function provide(input: { directory: string; fn: () => R }): Promise { + const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: input.directory }))) + return context.provide(ctx, () => input.fn()) +} + +export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 96a7e756de..949734f81a 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -6,7 +6,11 @@ import { InstanceStore } from "@/project/instance-store" import { Provider } from "@/provider/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.config" }) export const ConfigRoutes = lazy(() => new Hono() @@ -52,15 +56,28 @@ export const ConfigRoutes = lazy(() => }, }), validator("json", Config.Info.zod), - async (c) => - jsonRequest("ConfigRoutes.update", c, function* () { - const config = c.req.valid("json") - const cfg = yield* Config.Service - const store = yield* InstanceStore.Service - yield* cfg.update(config) - yield* store.dispose(yield* InstanceState.context) - return config - }), + async (c) => { + const result = await runRequest( + "ConfigRoutes.update", + c, + Effect.gen(function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return { config, ctx: yield* InstanceState.context } + }), + ) + const response = c.json(result.config) + void runRequest( + "ConfigRoutes.update.dispose", + c, + InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( + Effect.uninterruptible, + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ), + ) + return response + }, ) .get( "/providers", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index ce1b213729..0b4bc252c3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -18,7 +18,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" -import { InstanceRuntime } from "@/project/instance-runtime" +import { InstanceLayer } from "@/project/instance-layer" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -152,7 +152,6 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, - InstanceRuntime.layer, MCP.defaultLayer, ModelsDev.defaultLayer, Permission.defaultLayer, @@ -179,12 +178,13 @@ export function createRoutes(corsOptions?: CorsOptions) { ToolRegistry.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Bus.layer, AppFileSystem.defaultLayer, FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) } diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 494459500d..23707faf79 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -20,7 +20,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler return WorkspaceContext.provide({ workspaceID, async fn() { - return Instance.provide({ + return WithInstance.provide({ directory, async fn() { return next() diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index dbf693e8fc..f5f667222f 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -6,7 +6,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" import { AppRuntime } from "@/effect/app-runtime" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" import { Effect } from "effect" @@ -97,7 +97,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => - Instance.provide({ + WithInstance.provide({ directory: target.directory, async fn() { return next() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 2e9b6736f5..43453b561a 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,8 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { InstanceLayer } from "@/project/instance-layer" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -159,7 +160,12 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service + | AppFileSystem.Service + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Git.Service + | Project.Service + | InstanceStore.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -169,6 +175,7 @@ export const layer: Layer.Layer< const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const gitSvc = yield* Git.Service const project = yield* Project.Service + const store = yield* InstanceStore.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -251,13 +258,10 @@ export const layer: Layer.Layer< return } - const booted = yield* Effect.promise(() => - Instance.provide({ - directory: info.directory, - fn: () => undefined, - }) - .then(() => true) - .catch((error) => { + const booted = yield* store.load({ directory: info.directory }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.sync(() => { const message = errorMessage(error) log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { @@ -268,6 +272,7 @@ export const layer: Layer.Layer< }) return false }), + ), ) if (!booted) return @@ -579,7 +584,7 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( +export const appLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), @@ -587,4 +592,6 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) + export * as Worktree from "." diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index bce5e94598..9a92fc5072 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" type SessionUpdateParams = Parameters[0] @@ -262,7 +263,7 @@ function createFakeAgent() { describe("acp.agent event subscription", () => { test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, updates, stop } = createFakeAgent() @@ -297,7 +298,7 @@ describe("acp.agent event subscription", () => { test("does not emit user_message_chunk for live prompt parts", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -337,7 +338,7 @@ describe("acp.agent event subscription", () => { test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, chunks, stop } = createFakeAgent() @@ -389,7 +390,7 @@ describe("acp.agent event subscription", () => { test("does not create additional event subscriptions on repeated loadSession()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, calls, stop } = createFakeAgent() @@ -411,7 +412,7 @@ describe("acp.agent event subscription", () => { test("permission.asked events are handled and replied", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -450,7 +451,7 @@ describe("acp.agent event subscription", () => { test("permission prompt on session A does not block message updates for session B", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -537,7 +538,7 @@ describe("acp.agent event subscription", () => { test("streams running bash output snapshots and de-dupes identical snapshots", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -571,7 +572,7 @@ describe("acp.agent event subscription", () => { test("emits synthetic pending before first running update for any tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -616,7 +617,7 @@ describe("acp.agent event subscription", () => { test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() @@ -675,7 +676,7 @@ describe("acp.agent event subscription", () => { test("clears bash snapshot marker on pending state", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 44ed0692a4..6996e54b47 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import path from "path" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" @@ -23,7 +24,7 @@ afterEach(async () => { test("returns default native agents when no config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await load(tmp.path, (svc) => svc.list()) @@ -41,7 +42,7 @@ test("returns default native agents when no config", async () => { test("build agent has correct default properties", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -56,7 +57,7 @@ test("build agent has correct default properties", async () => { test("plan agent denies edits except .opencode/plans/*", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const plan = await load(tmp.path, (svc) => svc.get("plan")) @@ -71,7 +72,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { test("explore agent denies edit and write", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -87,7 +88,7 @@ test("explore agent denies edit and write", async () => { test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -103,7 +104,7 @@ test("explore agent asks for external directories and allows whitelisted externa test("general agent denies todo tools", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const general = await load(tmp.path, (svc) => svc.get("general")) @@ -117,7 +118,7 @@ test("general agent denies todo tools", async () => { test("compaction agent denies all permissions", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const compaction = await load(tmp.path, (svc) => svc.get("compaction")) @@ -143,7 +144,7 @@ test("custom agent from config creates new agent", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) @@ -172,7 +173,7 @@ test("custom agent config overrides native agent properties", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -195,7 +196,7 @@ test("agent disable removes agent from list", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -221,7 +222,7 @@ test("agent permission config merges with defaults", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -242,7 +243,7 @@ test("global permission config applies to all agents", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -261,7 +262,7 @@ test("agent steps/maxSteps config sets steps property", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -280,7 +281,7 @@ test("agent mode can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -297,7 +298,7 @@ test("agent name can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -314,7 +315,7 @@ test("agent prompt can be set from config", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -334,7 +335,7 @@ test("unknown agent properties are placed into options", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -357,7 +358,7 @@ test("agent options merge correctly", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -382,7 +383,7 @@ test("multiple custom agents can be defined", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) @@ -411,7 +412,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) @@ -423,7 +424,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn test("Agent.get returns undefined for non-existent agent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) @@ -434,7 +435,7 @@ test("Agent.get returns undefined for non-existent agent", async () => { test("default permission includes doom_loop and external_directory as ask", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -446,7 +447,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn test("webfetch is allowed by default", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -468,7 +469,7 @@ test("legacy tools config converts to permissions", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -490,7 +491,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () => }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -508,7 +509,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -521,7 +522,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally test("global tmp directory children are allowed for external_directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -546,7 +547,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -569,7 +570,7 @@ test("explicit Truncate.GLOB deny is respected", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -601,7 +602,7 @@ description: Permission skill. process.env.OPENCODE_TEST_HOME = tmp.path try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -617,7 +618,7 @@ description: Permission skill. test("defaultAgent returns build when no default_agent config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -632,7 +633,7 @@ test("defaultAgent respects default_agent config set to plan", async () => { default_agent: "plan", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -652,7 +653,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -667,7 +668,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => { default_agent: "explore", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') @@ -681,7 +682,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = default_agent: "compaction", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') @@ -695,7 +696,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn default_agent: "does_not_exist", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( @@ -713,7 +714,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -732,7 +733,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // build and plan are disabled, no primary-capable agents remain diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 89e8a66407..72e538aa3a 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "url" import { AppRuntime } from "../../src/effect/app-runtime" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" afterEach(async () => { @@ -39,7 +40,7 @@ test("plugin-registered agents appear in Agent.list", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 7e2138ea81..3e3d7a3e90 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,12 +3,13 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus integration: acquireRelease subscriber pattern", () => { diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index b24b79b33b..876cb1ed74 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { @@ -11,7 +12,7 @@ const TestEvent = { } function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus", () => { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9c4cbd788c..0a522b0850 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -7,6 +7,7 @@ import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" @@ -113,7 +114,7 @@ async function check(map: (dir: string) => string) { $schema: "https://opencode.ai/config.json", snapshot: false, }) - await Instance.provide({ + await WithInstance.provide({ directory: map(tmp.path), fn: async () => { const cfg = await load() @@ -131,7 +132,7 @@ async function check(map: (dir: string) => string) { test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -150,7 +151,7 @@ test("loads JSON config file", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -169,7 +170,7 @@ test("loads shell config field", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -191,7 +192,7 @@ test("updates config and preserves empty shell sentinel", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await save({ shell: "" }) @@ -269,7 +270,7 @@ test("loads formatter boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -287,7 +288,7 @@ test("loads lsp boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -324,7 +325,7 @@ test("ignores legacy tui keys in opencode config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -349,7 +350,7 @@ test("loads JSONC config file", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -377,7 +378,7 @@ test("jsonc overrides json in the same directory", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -400,7 +401,7 @@ test("handles environment variable substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -432,7 +433,7 @@ test("preserves env variables when adding $schema to config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -529,7 +530,7 @@ test("handles file inclusion substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -548,7 +549,7 @@ test("handles file inclusion with replacement tokens", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -604,7 +605,7 @@ test("handles agent configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -635,7 +636,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -665,7 +666,7 @@ test("handles command configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -690,7 +691,7 @@ test("migrates autoshare to share field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -717,7 +718,7 @@ test("migrates mode field to agent field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -749,7 +750,7 @@ Test agent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -782,7 +783,7 @@ Ordered permissions`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -820,7 +821,7 @@ Nested agent prompt`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -869,7 +870,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -914,7 +915,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -934,7 +935,7 @@ Nested command template`, test("updates config and writes to file", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } @@ -948,7 +949,7 @@ test("updates config and writes to file", async () => { test("gets config directories", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const dirs = await listDirs() @@ -978,7 +979,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as process.env.OPENCODE_CONFIG_DIR = tmp.extra try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await load() @@ -1013,7 +1014,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { ) try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer))) @@ -1146,7 +1147,7 @@ Helper subagent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1185,7 +1186,7 @@ test("merges instructions arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1224,7 +1225,7 @@ test("deduplicates duplicate instructions from global and local configs", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1359,7 +1360,7 @@ test("migrates legacy tools config to permissions - allow", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1390,7 +1391,7 @@ test("migrates legacy tools config to permissions - deny", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1420,7 +1421,7 @@ test("migrates legacy write tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1452,7 +1453,7 @@ test("managed settings override user settings", async () => { share: "disabled", }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1480,7 +1481,7 @@ test("managed settings override project settings", async () => { disabled_providers: ["openai"], }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1500,7 +1501,7 @@ test("missing managed settings file is not an error", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1527,7 +1528,7 @@ test("migrates legacy edit tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1556,7 +1557,7 @@ test("migrates legacy patch tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1588,7 +1589,7 @@ test("migrates mixed legacy tools config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1623,7 +1624,7 @@ test("merges legacy tools with existing permission config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1660,7 +1661,7 @@ test("permission config preserves user key order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1743,7 +1744,7 @@ test("project config can override MCP server enabled status", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1799,7 +1800,7 @@ test("MCP config deep merges preserving base config properties", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1850,7 +1851,7 @@ test("local .opencode config can override MCP from project config", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2139,7 +2140,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2170,7 +2171,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const directories = await listDirs() @@ -2194,7 +2195,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should still get default config (from global or defaults) @@ -2236,7 +2237,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // The relative instruction should be skipped without error @@ -2296,7 +2297,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path - await Instance.provide({ + await WithInstance.provide({ directory: projectTmp.path, fn: async () => { const config = await load() @@ -2331,7 +2332,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2365,7 +2366,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index ddd10f2e06..10a05e3b1e 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -14,6 +14,7 @@ import { Database } from "@/storage/db" import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { SessionID, MessageID, PartID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" @@ -101,7 +102,7 @@ afterEach(async () => { async function withInstance(fn: (dir: string) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return Instance.provide({ + return WithInstance.provide({ directory: tmp.path, fn: () => fn(tmp.path), }) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 699e713c22..f345cd0850 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import path from "path" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -30,7 +31,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await status() @@ -55,7 +56,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await read("tracked.txt") diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index bf5e7a175f..cdd2e211c2 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -5,6 +5,7 @@ import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -28,7 +29,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "Hello World", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -41,7 +42,7 @@ describe("file/index Filesystem patterns", () => { test("reads with Filesystem.exists() check", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Non-existent file should return empty content @@ -57,7 +58,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, " content with spaces \n\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -71,7 +72,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "empty.txt") await fs.writeFile(filepath, "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("empty.txt") @@ -86,7 +87,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "multiline.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("multiline.txt") @@ -103,7 +104,7 @@ describe("file/index Filesystem patterns", () => { const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await fs.writeFile(filepath, binaryContent) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("image.png") @@ -120,7 +121,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "binary.so") await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("binary.so") @@ -137,7 +138,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.json") await fs.writeFile(filepath, '{"key": "value"}', "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain("application/json") @@ -161,7 +162,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, `test.${ext}`) await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain(mime) @@ -175,7 +176,7 @@ describe("file/index Filesystem patterns", () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -193,7 +194,7 @@ describe("file/index Filesystem patterns", () => { test("reads .ignore file similarly", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ignorePath = path.join(tmp.path, ".ignore") @@ -208,7 +209,7 @@ describe("file/index Filesystem patterns", () => { test("handles missing .gitignore gracefully", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -226,7 +227,7 @@ describe("file/index Filesystem patterns", () => { test("reads untracked files via Filesystem.readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const untrackedPath = path.join(tmp.path, "untracked.txt") @@ -247,7 +248,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "readonly.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") @@ -264,7 +265,7 @@ describe("file/index Filesystem patterns", () => { test("handles errors in Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") @@ -279,7 +280,7 @@ describe("file/index Filesystem patterns", () => { const _filepath = path.join(tmp.path, "broken.png") // Don't create the file - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // read() handles missing images gracefully @@ -297,7 +298,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.ts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.ts") @@ -312,7 +313,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.mts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.mts") @@ -327,7 +328,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.sh") await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.sh") @@ -342,7 +343,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "Dockerfile") await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("Dockerfile") @@ -357,7 +358,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "simple text", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -372,7 +373,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.jpg") await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.jpg") @@ -387,7 +388,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -398,7 +399,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -416,7 +417,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -433,7 +434,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -454,7 +455,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.rm(filepath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -477,7 +478,7 @@ describe("file/index Filesystem patterns", () => { await fs.rm(path.join(tmp.path, "remove.txt")) await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -491,7 +492,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for non-git project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -503,7 +504,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for clean repo", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -526,7 +527,7 @@ describe("file/index Filesystem patterns", () => { for (let i = 0; i < 512; i++) modified[i] = i % 256 await fs.writeFile(filepath, modified) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -547,7 +548,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -571,7 +572,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -596,7 +597,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -615,7 +616,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") await fs.mkdir(path.join(tmp.path, "build")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -635,7 +636,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list("sub") @@ -650,7 +651,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../outside")).rejects.toThrow("Access denied") @@ -662,7 +663,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir() await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -692,7 +693,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -706,7 +707,7 @@ describe("file/index Filesystem patterns", () => { test("search works before explicit init", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await search({ query: "main", type: "file" }) @@ -718,7 +719,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns dirs sorted with hidden last", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -738,7 +739,7 @@ describe("file/index Filesystem patterns", () => { test("fuzzy matches file names", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -752,7 +753,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -769,7 +770,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only directories", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -786,7 +787,7 @@ describe("file/index Filesystem patterns", () => { test("respects limit", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -800,7 +801,7 @@ describe("file/index Filesystem patterns", () => { test("query starting with dot prefers hidden files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -815,7 +816,7 @@ describe("file/index Filesystem patterns", () => { test("search refreshes after init when files change", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -839,7 +840,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified content\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("file.txt") @@ -863,7 +864,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(filepath, "after\n", "utf-8") await $`git add .`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("staged.txt") @@ -880,7 +881,7 @@ describe("file/index Filesystem patterns", () => { await $`git add .`.cwd(tmp.path).quiet() await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("clean.txt") @@ -900,7 +901,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: one.path, fn: async () => { await init() @@ -911,7 +912,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: two.path, fn: async () => { await init() @@ -927,7 +928,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -941,7 +942,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 3a5ce2323e..5b59929ea5 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" @@ -55,7 +56,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") @@ -66,7 +67,7 @@ describe("File.read path traversal protection", () => { test("rejects deeply nested traversal", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( @@ -83,7 +84,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("valid.txt") @@ -97,7 +98,7 @@ describe("File.list path traversal protection", () => { test("rejects ../ traversal attempting to list /etc", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") @@ -112,7 +113,7 @@ describe("File.list path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await list("subdir") @@ -126,7 +127,7 @@ describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) @@ -140,7 +141,7 @@ describe("containsPath", () => { const subdir = path.join(tmp.path, "packages", "lib") await fs.mkdir(subdir, { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib @@ -156,7 +157,7 @@ describe("containsPath", () => { test("returns false for path outside both directory and worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath("/etc/passwd", Instance.current)).toBe(false) @@ -168,7 +169,7 @@ describe("containsPath", () => { test("returns false for path with .. escaping worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) @@ -179,7 +180,7 @@ describe("containsPath", () => { test("handles directory === worktree (running from repo root)", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) @@ -192,7 +193,7 @@ describe("containsPath", () => { test("non-git project does not allow arbitrary paths via worktree='/'", async () => { await using tmp = await tmpdir() // no git: true - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index e183f673f0..7e47c51351 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -9,6 +9,7 @@ import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -28,7 +29,7 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ function withWatcher(directory: string, body: Effect.Effect) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( @@ -193,7 +194,7 @@ describeWatcher("FileWatcher", () => { await withWatcher(tmp.path, Effect.void) // Now write a file — no watcher should be listening - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 970365f533..d47620f623 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -25,8 +25,9 @@ const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect. testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { - const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init })) + const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) try { + if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) return await Instance.restore(ctx, () => input.fn()) } finally { await runTestInstanceStore((store) => store.dispose(ctx)) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f3c7893d97..7d9f5a7155 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" function spawnFakeServer() { @@ -25,7 +26,7 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -48,7 +49,7 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -71,7 +72,7 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -94,7 +95,7 @@ describe("LSPClient interop", () => { test("initialize does not overclaim unsupported diagnostics capabilities", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -121,7 +122,7 @@ describe("LSPClient interop", () => { gamma: true, } - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -150,7 +151,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "first\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -193,7 +194,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -239,7 +240,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -286,7 +287,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -334,7 +335,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -387,7 +388,7 @@ describe("LSPClient interop", () => { await Bun.write(file, "class C {}\n") await Bun.write(related, "class D {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -451,7 +452,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 175717d056..5bc8f803d2 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -48,6 +48,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { AppRuntime } = await import("../../src/effect/app-runtime") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -73,7 +74,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Trigger MCP initialization - it will fail to connect but we can check the transport options @@ -112,7 +113,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( test("headers are passed to transports when oauth is explicitly disabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 @@ -150,7 +151,7 @@ test("headers are passed to transports when oauth is explicitly disabled", async test("no requestInit when headers are not provided", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 2ba487f3f5..10547c9f08 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -172,6 +172,7 @@ beforeEach(() => { // Import after mocks const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") // --- Helper --- @@ -193,7 +194,7 @@ function withInstance( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 8b29f6d1e3..3cf6774215 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -112,6 +112,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") test("first connect to OAuth server shows needs_auth instead of failed", async () => { @@ -132,7 +133,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await Effect.runPromise( @@ -162,7 +163,7 @@ test("state() generates a new state when none is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -203,7 +204,7 @@ test("state() returns existing state when one is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -252,7 +253,7 @@ test("authenticate() stores a connected client when auth completes without redir }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise( diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 3a6df02a15..20cb90a18e 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -106,6 +106,7 @@ const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -127,7 +128,7 @@ test("BrowserOpenFailed event is published when open() throws", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = true @@ -183,7 +184,7 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false @@ -237,7 +238,7 @@ test("open() is called with the authorization URL", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index d4f9192c76..64b93bb8bc 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" +import { WithInstance } from "../src/project/with-instance" import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" @@ -158,7 +159,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -183,7 +184,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -208,7 +209,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -235,7 +236,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -273,7 +274,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -304,7 +305,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 4d66784d81..1c3d6fc563 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,6 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { disposeAllInstances, @@ -1006,7 +1007,7 @@ it.live("pending permission rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts index bb8d43e015..c01450549b 100644 --- a/packages/opencode/test/project/instance-bootstrap-regression.test.ts +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -5,6 +5,7 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -50,7 +51,7 @@ async function bootstrapFixture() { test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { await using tmp = await bootstrapFixture() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => "ok", }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index bc8809af9c..655e381b9a 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -5,17 +5,23 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +let bootstrapRun: Effect.Effect = Effect.void +const noopBootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }), +) const it = testEffect( Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), ) afterEach(async () => { + bootstrapRun = Effect.void await disposeAllInstances() }) @@ -32,18 +38,16 @@ describe("InstanceStore", () => { }), ) - it.live("runs load init with InstanceRef provided", () => + it.live("runs bootstrap with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service let initializedDirectory: string | undefined - yield* store.load({ - directory: dir, - init: Effect.gen(function* () { - initializedDirectory = (yield* InstanceRef)?.directory - }), + bootstrapRun = Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory }) + yield* store.load({ directory: dir }) expect(initializedDirectory).toBe(dir) expect(() => Instance.current).toThrow() @@ -56,18 +60,11 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let initialized = 0 - const first = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - const second = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), + bootstrapRun = Effect.sync(() => { + initialized++ }) + const first = yield* store.load({ directory: dir }) + const second = yield* store.load({ directory: dir }) expect(second).toBe(first) expect(initialized).toBe(1) @@ -82,27 +79,19 @@ describe("InstanceStore", () => { const release = Promise.withResolvers() let initialized = 0 - const first = yield* store - .load({ - directory: dir, - init: Effect.promise(async () => { - initialized++ - started.resolve() - await release.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + initialized++ + started.resolve() + await release.promise + }) + const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => started.promise) - const second = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.sync(() => { + initialized++ + }) + const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) expect(initialized).toBe(1) release.resolve() @@ -119,27 +108,21 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let attempts = 0 - const failed = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - throw new Error("init failed") - }), - }) - .pipe( - Effect.as(false), - Effect.catchCause(() => Effect.succeed(true)), - ) + bootstrapRun = Effect.sync(() => { + attempts++ + throw new Error("init failed") + }) + const failed = yield* store.load({ directory: dir }).pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) expect(failed).toBe(true) - const ctx = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - }), + bootstrapRun = Effect.sync(() => { + attempts++ }) + const ctx = yield* store.load({ directory: dir }) expect(ctx.directory).toBe(dir) expect(attempts).toBe(2) @@ -173,15 +156,11 @@ describe("InstanceStore", () => { yield* Effect.addFinalizer(() => Effect.sync(off)) const first = yield* store.load({ directory: dir }) - const reload = yield* store - .reload({ - directory: dir, - init: Effect.promise(async () => { - reloading.resolve() - await releaseReload.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + reloading.resolve() + await releaseReload.promise + }) + const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => reloading.promise) const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) @@ -242,12 +221,12 @@ describe("InstanceStore", () => { }), ) - it.live("keeps Instance.provide as the legacy ALS wrapper", () => + it.live("provides legacy Promise callers with instance ALS", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const directory = yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: dir, fn: () => Instance.directory, }), @@ -258,21 +237,4 @@ describe("InstanceStore", () => { }), ) - it.live("does not install legacy ALS around Effect init", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - - const directory = yield* Effect.promise(() => - Instance.provide({ - directory: dir, - init: Effect.sync(() => { - expect(() => Instance.current).toThrow() - }), - fn: () => Instance.directory, - }), - ) - - expect(directory).toBe(dir) - }), - ) }) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 0d0e46fe48..6fb0e251d3 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -7,6 +7,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { GlobalBus } from "../../src/bus/global" import { Vcs } from "@/project/vcs" @@ -18,7 +19,7 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe // --------------------------------------------------------------------------- async function withVcs(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( @@ -36,7 +37,7 @@ async function withVcs(directory: string, body: () => Promise) { } function withVcsOnly(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 60c66981d5..a89fda6ca5 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" @@ -138,7 +139,7 @@ describe("Worktree", () => { expect(props.branch).toBe(info.branch) yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: info.directory, fn: () => InstanceRuntime.disposeInstance(Instance.current), }), @@ -163,7 +164,7 @@ describe("Worktree", () => { yield* Effect.promise(() => ready) yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: info.directory, fn: () => InstanceRuntime.disposeInstance(Instance.current), }), diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 43b23dafad..c35a03d78b 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -5,6 +5,7 @@ import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" import { Global } from "@opencode-ai/core/global" @@ -43,13 +44,11 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -68,13 +67,11 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -123,14 +120,12 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -169,13 +164,11 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -201,12 +194,10 @@ test("Bedrock: includes custom endpoint in options when specified", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( @@ -234,15 +225,13 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -277,12 +266,10 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix @@ -314,12 +301,10 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -350,12 +335,10 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -386,12 +369,10 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 84478a34c4..8bb3b96347 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -9,6 +9,7 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" // import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" // import { Global } from "@opencode-ai/core/global" @@ -25,7 +26,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-gitlab-token") @@ -56,7 +57,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -95,7 +96,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -130,7 +131,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -162,7 +163,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") @@ -193,7 +194,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "env-token") @@ -216,7 +217,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -252,7 +253,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -277,7 +278,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -301,7 +302,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -349,7 +350,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -372,7 +373,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -396,7 +397,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 924f42888b..cdb9d20572 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -5,6 +5,7 @@ import path from "path" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" @@ -80,12 +81,10 @@ test("provider loaded from env variable", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders @@ -114,7 +113,7 @@ test("provider loaded from config with apiKey option", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -135,12 +134,10 @@ test("disabled_providers excludes provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -159,13 +156,11 @@ test("enabled_providers restricts to only listed providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -189,12 +184,10 @@ test("model whitelist filters models for provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -220,12 +213,10 @@ test("model blacklist excludes specific models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -255,12 +246,10 @@ test("custom model alias via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() @@ -301,7 +290,7 @@ test("custom provider with npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -358,7 +347,7 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -392,12 +381,10 @@ test("env variable takes precedence, config merges options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "env-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "env-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged @@ -418,12 +405,10 @@ test("getModel returns model for valid provider/model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") @@ -445,12 +430,10 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) @@ -467,7 +450,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() @@ -498,12 +481,10 @@ test("defaultModel returns first available model when no config set", async () = ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() @@ -523,12 +504,10 @@ test("defaultModel respects config model setting", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") @@ -565,7 +544,7 @@ test("provider with baseURL from config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -603,7 +582,7 @@ test("model cost defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -638,12 +617,10 @@ test("model options are merged from existing model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") @@ -667,12 +644,10 @@ test("provider removed when all models filtered out", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -690,12 +665,10 @@ test("closest finds model by partial match", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") @@ -715,7 +688,7 @@ test("closest returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await closest(ProviderID.make("nonexistent"), ["model"]) @@ -745,12 +718,10 @@ test("getModel uses realIdByKey for aliased models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -791,7 +762,7 @@ test("provider api field sets model api.url", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -831,7 +802,7 @@ test("explicit baseURL overrides api field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -860,12 +831,10 @@ test("model inherits properties from existing database model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") @@ -888,12 +857,10 @@ test("disabled_providers prevents loading even with env var", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-openai-key") const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -912,13 +879,11 @@ test("enabled_providers with empty array allows no providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) }, @@ -942,12 +907,10 @@ test("whitelist and blacklist can be combined", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -984,7 +947,7 @@ test("model modalities default correctly", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1027,7 +990,7 @@ test("model with custom cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1051,12 +1014,10 @@ test("getSmallModel returns appropriate small model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") @@ -1076,12 +1037,10 @@ test("getSmallModel respects config small_model override", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") @@ -1124,13 +1083,11 @@ test("multiple providers can be configured simultaneously", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() @@ -1169,7 +1126,7 @@ test("provider with custom npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1203,12 +1160,10 @@ test("model alias name defaults to alias key when id differs", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, @@ -1243,12 +1198,10 @@ test("provider with multiple env var options only includes apiKey when single en ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("MULTI_ENV_KEY_1", "test-key") - }).pipe(Effect.asVoid), fn: async () => { + set("MULTI_ENV_KEY_1", "test-key") const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set @@ -1285,12 +1238,10 @@ test("provider with single env var includes apiKey automatically", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("SINGLE_ENV_KEY", "my-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("SINGLE_ENV_KEY", "my-api-key") const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key @@ -1322,12 +1273,10 @@ test("model cost overrides existing cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) @@ -1372,7 +1321,7 @@ test("completely new provider not in database can be configured", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1401,14 +1350,12 @@ test("disabled_providers and enabled_providers interaction", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1446,7 +1393,7 @@ test("model with tool_call false", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1481,7 +1428,7 @@ test("model defaults tool_call to true when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1520,7 +1467,7 @@ test("model headers are preserved", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1559,13 +1506,11 @@ test("provider env fallback - second env var used if first missing", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() @@ -1584,12 +1529,10 @@ test("getModel returns consistent results", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) @@ -1625,7 +1568,7 @@ test("provider name defaults to id when not in database", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1645,12 +1588,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here @@ -1673,12 +1614,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here @@ -1701,7 +1640,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const provider = await getProvider(ProviderID.make("nonexistent")) @@ -1721,12 +1660,10 @@ test("getProvider returns provider info", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") @@ -1745,12 +1682,10 @@ test("closest returns undefined when no partial match found", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, @@ -1768,12 +1703,10 @@ test("closest checks multiple query terms in order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() @@ -1808,7 +1741,7 @@ test("model limit defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1840,12 +1773,10 @@ test("provider options are deeply merged", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1878,12 +1809,10 @@ test("custom model inherits npm package from models.dev provider config", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() @@ -1913,12 +1842,10 @@ test("custom model inherits api.url from models.dev provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENROUTER_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENROUTER_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2046,12 +1973,10 @@ test("model variants are generated for reasoning models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2084,12 +2009,10 @@ test("model variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2127,12 +2050,10 @@ test("model variants can be customized via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2166,12 +2087,10 @@ test("disabled key is stripped from variant config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() @@ -2204,12 +2123,10 @@ test("all variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2242,12 +2159,10 @@ test("variant config merges with generated variants", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2280,12 +2195,10 @@ test("variants filtered in second pass for database models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() @@ -2329,7 +2242,7 @@ test("custom model with variants enabled and disabled", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -2384,12 +2297,10 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") @@ -2429,12 +2340,10 @@ test("Google Vertex: supports OpenAI compatible models", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2455,14 +2364,12 @@ test("cloudflare-ai-gateway loads with env variables", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, @@ -2487,14 +2394,12 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ @@ -2542,7 +2447,7 @@ test("plugin config providers persist after instance dispose", async () => { }, }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise( @@ -2559,7 +2464,7 @@ test("plugin config providers persist after instance dispose", async () => { await disposeAllInstances() - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => list(), }) @@ -2590,13 +2495,11 @@ test("plugin config enabled and disabled providers are honored", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -2616,7 +2519,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2639,7 +2542,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) @@ -2660,7 +2563,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2694,7 +2597,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }), ) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 9ef9741bad..662042b64c 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" @@ -10,7 +11,7 @@ describe("pty", () => { test("does not leak output when websocket objects are reused", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -60,7 +61,7 @@ describe("pty", () => { test("does not leak output when Bun recycles websocket objects before re-connect", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -105,7 +106,7 @@ describe("pty", () => { test("treats in-place socket data mutation as the same connection", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 3e4d658355..8c5d804b73 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -3,6 +3,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" @@ -27,7 +28,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -68,7 +69,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts index 7b8b4d67ca..00e965d25e 100644 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { Shell } from "../../src/shell/shell" import { tmpdir } from "../fixture/fixture" @@ -17,7 +18,7 @@ describe("pty shell args", () => { "does not add login args to pwsh", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -47,7 +48,7 @@ describe("pty shell args", () => { "adds login args to bash", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -78,7 +79,7 @@ describe("pty configured shell", () => { await using dir = await tmpdir({ config: { shell: Shell.name(configured) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 9e577ec3cd..4e2c8ef9bb 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" @@ -398,7 +399,7 @@ it.live("pending question rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index a5ab7b8f36..9368089511 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import z from "zod" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -28,11 +29,11 @@ describe("session.listGlobal", () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) - const firstSession = await Instance.provide({ + const firstSession = await WithInstance.provide({ directory: first.path, fn: async () => svc.create({ title: "first-session" }), }) - const secondSession = await Instance.provide({ + const secondSession = await WithInstance.provide({ directory: second.path, fn: async () => svc.create({ title: "second-session" }), }) @@ -58,12 +59,12 @@ describe("session.listGlobal", () => { test("excludes archived sessions by default", async () => { await using tmp = await tmpdir({ git: true }) - const archived = await Instance.provide({ + const archived = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "archived-session" }), }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }), }) @@ -82,12 +83,12 @@ describe("session.listGlobal", () => { test("supports cursor pagination", async () => { await using tmp = await tmpdir({ git: true }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 5f36a32746..8684edf134 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" @@ -126,12 +127,12 @@ describe("experimental HttpApi", () => { test("serves global session list through Hono bridge", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 7a889aea04..410dbe7426 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -10,8 +10,8 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceRuntime } from "../../src/project/instance-runtime" import { Instance } from "../../src/project/instance" +import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -41,7 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceRuntime.layer, + InstanceLayer.layer, Project.defaultLayer, Workspace.defaultLayer, ), diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 396d04feb8..f442df5770 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,6 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -59,7 +60,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) ) yield* Effect.addFinalizer(() => Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 8118aa7842..c45a81838a 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,6 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -91,7 +92,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 771fb57019..ce774ccfd0 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,6 +5,7 @@ import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -226,7 +227,7 @@ function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) return call( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 02d590f918..70fe2d81b3 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -9,6 +9,7 @@ import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -44,7 +45,7 @@ function pathFor(path: string, params: Record) { function createSession(directory: string, input?: Session.CreateInput) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession(Session.Service.use((svc) => svc.create(input))), }), @@ -54,7 +55,7 @@ function createSession(directory: string, input?: Session.CreateInput) { function createTextMessage(directory: string, sessionID: SessionID, text: string) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession( diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index d022c37974..b85658ea1e 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" @@ -38,7 +39,7 @@ describe("sync HttpApi", () => { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const info = spyOn(Log.create({ service: "server.sync" }), "info") - const session = await Instance.provide({ + const session = await WithInstance.provide({ directory: tmp.path, fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))), }) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 43f188e741..1ccc9bc8e6 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" @@ -31,7 +32,7 @@ afterEach(async () => { describe("session action routes", () => { test("abort route returns success", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 7d479a73b0..20478dde84 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -40,20 +41,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -73,20 +74,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -106,22 +107,22 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "current" }), }) - const deeper = await Instance.provide({ + const deeper = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), fn: async () => svc.create({ title: "deeper" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -146,14 +147,14 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "legacy-current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "legacy-sibling" }), }) @@ -175,7 +176,7 @@ describe("session.list", () => { test("filters root sessions", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root-session" }) @@ -192,7 +193,7 @@ describe("session.list", () => { test("filters by start time", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "new-session" }) @@ -206,7 +207,7 @@ describe("session.list", () => { test("filters by search term", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "unique-search-term-abc" }) @@ -223,7 +224,7 @@ describe("session.list", () => { test("respects limit parameter", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "session-1" }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e70847baf2..e3c5e83136 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" @@ -76,7 +77,7 @@ describe("session messages endpoint", () => { test("returns cursor headers for older pages", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -105,7 +106,7 @@ describe("session messages endpoint", () => { test("keeps full-history responses when limit is omitted", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -126,7 +127,7 @@ describe("session messages endpoint", () => { test("rejects invalid cursors and missing sessions", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -147,7 +148,7 @@ describe("session messages endpoint", () => { test("does not truncate large legacy limit requests", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index b3230d4b8a..13edca1458 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -30,7 +31,7 @@ afterEach(async () => { describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -56,7 +57,7 @@ describe("tui.selectSession endpoint", () => { test("should return 404 when session does not exist", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -78,7 +79,7 @@ describe("tui.selectSession endpoint", () => { test("should return 400 when session ID format is invalid", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f3f7cbaef7..df83adb8d4 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -10,6 +10,7 @@ import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -792,7 +793,7 @@ describe("session.compaction.prune", () => { describe("session.compaction.process", () => { test("throws when parent is not a user message", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -822,7 +823,7 @@ describe("session.compaction.process", () => { test("publishes compacted event on continue", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -872,7 +873,7 @@ describe("session.compaction.process", () => { test("marks summary message as errored on compact result", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -910,7 +911,7 @@ describe("session.compaction.process", () => { test("adds synthetic continue prompt when auto is enabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -951,7 +952,7 @@ describe("session.compaction.process", () => { test("persists tail_start_id for retained recent turns", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -998,7 +999,7 @@ describe("session.compaction.process", () => { test("shrinks retained tail to fit preserve token budget", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1047,7 +1048,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1096,7 +1097,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1218,7 +1219,7 @@ describe("session.compaction.process", () => { test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1261,7 +1262,7 @@ describe("session.compaction.process", () => { test("replays the prior user turn on overflow when earlier context exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1309,7 +1310,7 @@ describe("session.compaction.process", () => { test("falls back to overflow guidance when no replayable turn exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1369,7 +1370,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1443,7 +1444,7 @@ describe("session.compaction.process", () => { const ready = defer() await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1545,7 +1546,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1587,7 +1588,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1639,7 +1640,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1707,7 +1708,7 @@ describe("session.compaction.process", () => { stub.push(reply("summary one")) stub.push(reply("summary two")) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1779,7 +1780,7 @@ describe("session.compaction.process", () => { test("ignores previous summaries when sizing the retained tail", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index c648d62be8..7b96084832 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -6,6 +6,7 @@ import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@/provider/models" @@ -338,7 +339,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -425,7 +426,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -515,7 +516,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -629,7 +630,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -745,7 +746,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -864,7 +865,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -982,7 +983,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) @@ -1223,7 +1224,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 17370bbe62..35b67f7a07 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -123,7 +124,7 @@ async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tai describe("MessageV2.page", () => { test("returns sync result", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -139,7 +140,7 @@ describe("MessageV2.page", () => { }) test("pages backward with opaque cursors", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -167,7 +168,7 @@ describe("MessageV2.page", () => { }) test("returns items in chronological order within a page", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -182,7 +183,7 @@ describe("MessageV2.page", () => { }) test("returns empty items for session with no messages", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -198,7 +199,7 @@ describe("MessageV2.page", () => { }) test("throws NotFoundError for non-existent session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const fake = "non-existent-session" as SessionID @@ -208,7 +209,7 @@ describe("MessageV2.page", () => { }) test("handles exact limit boundary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -225,7 +226,7 @@ describe("MessageV2.page", () => { }) test("limit of 1 returns single newest message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -242,7 +243,7 @@ describe("MessageV2.page", () => { }) test("hydrates multiple parts per message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -266,7 +267,7 @@ describe("MessageV2.page", () => { }) test("accepts cursors from fractional timestamps", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -284,7 +285,7 @@ describe("MessageV2.page", () => { }) test("messages with same timestamp are ordered by id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -304,7 +305,7 @@ describe("MessageV2.page", () => { }) test("does not return messages from other sessions", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -326,7 +327,7 @@ describe("MessageV2.page", () => { }) test("large limit returns all messages without cursor", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -346,7 +347,7 @@ describe("MessageV2.page", () => { describe("MessageV2.stream", () => { test("yields items newest first", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -361,7 +362,7 @@ describe("MessageV2.stream", () => { }) test("yields nothing for empty session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -375,7 +376,7 @@ describe("MessageV2.stream", () => { }) test("yields single message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -391,7 +392,7 @@ describe("MessageV2.stream", () => { }) test("hydrates parts for each yielded message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -409,7 +410,7 @@ describe("MessageV2.stream", () => { }) test("handles sets exceeding internal page size", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -426,7 +427,7 @@ describe("MessageV2.stream", () => { }) test("is a sync generator", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -447,7 +448,7 @@ describe("MessageV2.stream", () => { describe("MessageV2.parts", () => { test("returns parts for a message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -464,7 +465,7 @@ describe("MessageV2.parts", () => { }) test("returns empty array for message with no parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -479,7 +480,7 @@ describe("MessageV2.parts", () => { }) test("returns multiple parts in order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -512,7 +513,7 @@ describe("MessageV2.parts", () => { }) test("returns empty for non-existent message id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { await svc.create({}) @@ -523,7 +524,7 @@ describe("MessageV2.parts", () => { }) test("parts contain sessionID and messageID", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -541,7 +542,7 @@ describe("MessageV2.parts", () => { describe("MessageV2.get", () => { test("returns message with hydrated parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -560,7 +561,7 @@ describe("MessageV2.get", () => { }) test("throws NotFoundError for non-existent message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -575,7 +576,7 @@ describe("MessageV2.get", () => { }) test("scopes by session id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -593,7 +594,7 @@ describe("MessageV2.get", () => { }) test("returns message with multiple parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -616,7 +617,7 @@ describe("MessageV2.get", () => { }) test("returns assistant message with correct role", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -642,7 +643,7 @@ describe("MessageV2.get", () => { }) test("returns message with zero parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -660,7 +661,7 @@ describe("MessageV2.get", () => { describe("MessageV2.filterCompacted", () => { test("returns all messages when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -677,7 +678,7 @@ describe("MessageV2.filterCompacted", () => { }) test("stops at compaction boundary and returns chronological order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -721,7 +722,7 @@ describe("MessageV2.filterCompacted", () => { }) test("does not break on compaction part without matching summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -739,7 +740,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant with error even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -764,7 +765,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant without finish even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -785,7 +786,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains original tail when compaction stores tail_start_id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -841,7 +842,7 @@ describe("MessageV2.filterCompacted", () => { }) test("fork remaps compaction tail_start_id for filterCompacted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -907,7 +908,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains an assistant tail when compaction starts inside a turn", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -971,7 +972,7 @@ describe("MessageV2.filterCompacted", () => { }) test("prefers latest compaction boundary when repeated compactions exist", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1093,7 +1094,7 @@ describe("MessageV2.cursor", () => { describe("MessageV2 consistency", () => { test("page hydration matches get for each message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1112,7 +1113,7 @@ describe("MessageV2 consistency", () => { }) test("parts from get match standalone parts call", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1128,7 +1129,7 @@ describe("MessageV2 consistency", () => { }) test("stream collects same messages as exhaustive page iteration", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("MessageV2 consistency", () => { }) test("filterCompacted of full stream returns same as Array.from when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 99f20b44dc..bb69e459bc 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import { Bus } from "../../src/bus" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -34,7 +35,7 @@ function updatePart(part: T) { describe("session.created event", () => { test("should emit session.created event when session is created", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { let eventReceived = false @@ -63,7 +64,7 @@ describe("session.created event", () => { }) test("session.created event should be emitted before session.updated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const events: string[] = [] @@ -95,7 +96,7 @@ describe("step-finish token propagation via Bus event", () => { test( "non-zero tokens propagate through PartUpdated event", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const info = await create({}) @@ -166,7 +167,7 @@ describe("Session", () => { test("remove works without an instance", async () => { await using tmp = await tmpdir({ git: true }) - const info = await Instance.provide({ + const info = await WithInstance.provide({ directory: tmp.path, fn: () => create({ title: "remove-without-instance" }), }) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index bdf95caed5..da2ffb7937 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -5,6 +5,7 @@ import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" const projectRoot = path.join(__dirname, "../..") @@ -15,7 +16,7 @@ const hasApiKey = !!process.env.ANTHROPIC_API_KEY // Helper to run test within Instance context async function withInstance(fn: () => Promise): Promise { - return Instance.provide({ + return WithInstance.provide({ directory: projectRoot, fn, }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index c3216e1c58..99ddfe72d4 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -47,7 +48,7 @@ function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effe test("tracks deleted files correctly", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -62,7 +63,7 @@ test("tracks deleted files correctly", async () => { test("revert should remove new files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -86,7 +87,7 @@ test("revert should remove new files", async () => { test("revert in subdirectory", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -113,7 +114,7 @@ test("revert in subdirectory", async () => { test("multiple file operations", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -145,7 +146,7 @@ test("multiple file operations", async () => { test("empty directory handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -160,7 +161,7 @@ test("empty directory handling", async () => { test("binary file handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -184,7 +185,7 @@ test("binary file handling", async () => { test("symlink handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -199,7 +200,7 @@ test("symlink handling", async () => { test("file under size limit handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -214,7 +215,7 @@ test("file under size limit handling", async () => { test("large added files are skipped", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -231,7 +232,7 @@ test("large added files are skipped", async () => { test("nested directory revert", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -256,7 +257,7 @@ test("nested directory revert", async () => { test("special characters in filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -276,7 +277,7 @@ test("special characters in filenames", async () => { test("revert with empty patches", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should not crash with empty patches @@ -290,7 +291,7 @@ test("revert with empty patches", async () => { test("patch with invalid hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -309,7 +310,7 @@ test("patch with invalid hash", async () => { test("revert non-existent file", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -333,7 +334,7 @@ test("revert non-existent file", async () => { test("unicode filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -373,7 +374,7 @@ test("unicode filenames", async () => { test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const chineseFile = fwd(tmp.path, "文件.txt") @@ -402,7 +403,7 @@ test.skip("unicode filenames modification and restore", async () => { test("unicode filenames in subdirectories", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -428,7 +429,7 @@ test("unicode filenames in subdirectories", async () => { test("very long filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -455,7 +456,7 @@ test("very long filenames", async () => { test("hidden files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -475,7 +476,7 @@ test("hidden files", async () => { test("nested symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -495,7 +496,7 @@ test("nested symlinks", async () => { test("file permissions and ownership changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -516,7 +517,7 @@ test("file permissions and ownership changes", async () => { test("circular symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -547,7 +548,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -576,7 +577,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" test("gitignore changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -600,7 +601,7 @@ test("gitignore changes", async () => { test("files tracked in snapshot but now gitignored are filtered out", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // First, create a file and snapshot it @@ -634,7 +635,7 @@ test("files tracked in snapshot but now gitignored are filtered out", async () = test("gitignore updated between track calls filters from diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // a.txt is already committed from bootstrap - track it in snapshot @@ -669,7 +670,7 @@ test("gitignore updated between track calls filters from diff", async () => { test("git info exclude changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -695,7 +696,7 @@ test("git info exclude changes", async () => { test("git info exclude keeps global excludes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const global = `${tmp.path}/global.ignore` @@ -731,7 +732,7 @@ test("git info exclude keeps global excludes", async () => { test("concurrent file operations during patch", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -763,7 +764,7 @@ test("snapshot state isolation between projects", async () => { await using tmp1 = await bootstrap() await using tmp2 = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp1.path, fn: async () => { const before1 = await run(tmp1.path, (snapshot) => snapshot.track()) @@ -773,7 +774,7 @@ test("snapshot state isolation between projects", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp2.path, fn: async () => { const before2 = await run(tmp2.path, (snapshot) => snapshot.track()) @@ -793,14 +794,14 @@ test("patch detects changes in secondary worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -825,7 +826,7 @@ test("revert only removes files in invoking worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() @@ -834,7 +835,7 @@ test("revert only removes files in invoking worktree", async () => { const primaryFile = `${tmp.path}/worktree.txt` await Filesystem.write(primaryFile, "primary content") - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -869,14 +870,14 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -903,7 +904,7 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( test("track with no changes returns same hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const hash1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -922,7 +923,7 @@ test("track with no changes returns same hash", async () => { test("diff function with various changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -943,7 +944,7 @@ test("diff function with various changes", async () => { test("restore function", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -977,7 +978,7 @@ test("restore function", async () => { test("revert should not delete files that existed but were deleted in snapshot", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1007,7 +1008,7 @@ test("revert should not delete files that existed but were deleted in snapshot", test("revert preserves file that existed in snapshot when deleted then recreated", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/existing.txt`, "original content") @@ -1044,7 +1045,7 @@ test("revert preserves file that existed in snapshot when deleted then recreated test("diffFull sets status based on git change type", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") @@ -1090,7 +1091,7 @@ test("diffFull sets status based on git change type", async () => { test("diffFull with new file additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1115,7 +1116,7 @@ test("diffFull with new file additions", async () => { test("diffFull with a large interleaved mixed diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) @@ -1178,7 +1179,7 @@ test("diffFull with a large interleaved mixed diff", async () => { test("diffFull preserves git diff order across batch boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) @@ -1204,7 +1205,7 @@ test("diffFull preserves git diff order across batch boundaries", async () => { test("diffFull with file modifications", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1230,7 +1231,7 @@ test("diffFull with file modifications", async () => { test("diffFull with file deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1255,7 +1256,7 @@ test("diffFull with file deletions", async () => { test("diffFull with multiple line additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1281,7 +1282,7 @@ test("diffFull with multiple line additions", async () => { test("diffFull with addition and deletion", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1313,7 +1314,7 @@ test("diffFull with addition and deletion", async () => { test("diffFull with multiple additions and deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1355,7 +1356,7 @@ test("diffFull with multiple additions and deletions", async () => { test("diffFull with no changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1372,7 +1373,7 @@ test("diffFull with no changes", async () => { test("diffFull with binary file changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1395,7 +1396,7 @@ test("diffFull with binary file changes", async () => { test("diffFull with whitespace changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") @@ -1419,7 +1420,7 @@ test("diffFull with whitespace changes", async () => { test("revert with overlapping files across patches uses first patch hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Write initial content and snapshot @@ -1453,7 +1454,7 @@ test("revert with overlapping files across patches uses first patch hash", async test("revert preserves patch order when the same hash appears again", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await $`mkdir -p ${tmp.path}/foo`.quiet() @@ -1490,7 +1491,7 @@ test("revert preserves patch order when the same hash appears again", async () = test("revert handles large mixed batches across chunk boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c4cccc6eb5..fd24b557b3 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs/promises" import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -97,7 +98,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const modifyPath = path.join(fixture.path, "modify.txt") @@ -149,7 +150,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -179,7 +180,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi.txt") @@ -199,7 +200,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const bom = String.fromCharCode(0xfeff) @@ -228,7 +229,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "insert_only.txt") @@ -247,7 +248,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "no_newline.txt") @@ -269,7 +270,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -292,7 +293,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -317,7 +318,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "duplicate.txt") @@ -335,7 +336,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" @@ -351,7 +352,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" @@ -365,7 +366,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const dirPath = path.join(fixture.path, "dir") @@ -382,7 +383,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" @@ -396,7 +397,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "modify.txt") @@ -414,7 +415,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = @@ -432,7 +433,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "tail.txt") @@ -450,7 +451,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "two_chunks.txt") @@ -468,7 +469,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi_ctx.txt") @@ -486,7 +487,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "eof_anchor.txt") @@ -508,7 +509,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `cat <<'EOF' @@ -529,7 +530,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `< { const target = path.join(fixture.path, "trailing_ws.txt") @@ -570,7 +571,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "leading_ws.txt") @@ -590,7 +591,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "unicode.txt") diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 2c381ad047..23ae0e9090 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -73,7 +74,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "newfile.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -102,7 +103,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -131,7 +132,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -156,7 +157,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "new.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -191,7 +192,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "existing.txt") await fs.writeFile(filepath, "old content here", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -220,7 +221,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -250,7 +251,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -275,7 +276,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -300,7 +301,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "actual content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -325,7 +326,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -352,7 +353,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "original", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -387,7 +388,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -413,7 +414,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -439,7 +440,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -464,7 +465,7 @@ describe("tool.edit", () => { const dirpath = path.join(tmp.path, "adir") await fs.mkdir(dirpath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -489,7 +490,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -558,7 +559,7 @@ describe("tool.edit", () => { }, }) - return await Instance.provide({ + return await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -702,7 +703,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index ea1d340ce8..5914918178 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,6 +3,7 @@ import path from "path" import { Effect } from "effect" import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -38,7 +39,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp", fn: async () => { await assertExternalDirectory(ctx) @@ -51,7 +52,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for paths inside Instance.directory", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) @@ -68,7 +69,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside/file.txt" const expected = glob(path.join(path.dirname(target), "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target) @@ -88,7 +89,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside" const expected = glob(path.join(target, "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target, { kind: "directory" }) @@ -104,7 +105,7 @@ describe("tool.assertExternalDirectory", () => { test("skips prompting when bypass=true", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) @@ -131,7 +132,7 @@ describe("tool.assertExternalDirectory", () => { .replaceAll("\\", "/") .toLowerCase() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, alt) @@ -152,7 +153,7 @@ describe("tool.assertExternalDirectory", () => { const root = path.parse(tmp.path).root const target = path.join(root, "boot.ini") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, target) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index e68d16ba81..9b5c17c222 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -6,6 +6,7 @@ import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" @@ -140,7 +141,7 @@ const mustTruncate = (result: { describe("tool.shell", () => { each("basic", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -163,7 +164,7 @@ describe("tool.shell", () => { await using tmp = await tmpdir({ config: { shell: "fish" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -190,7 +191,7 @@ describe("tool.shell", () => { describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -213,7 +214,7 @@ describe("tool.shell permissions", () => { each("asks for bash permission with multiple commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -239,7 +240,7 @@ describe("tool.shell permissions", () => { test( `parses PowerShell conditionals for permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -269,7 +270,7 @@ describe("tool.shell permissions", () => { `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -297,7 +298,7 @@ describe("tool.shell permissions", () => { } each("asks for external_directory permission for wildcard external paths", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -333,7 +334,7 @@ describe("tool.shell permissions", () => { await Bun.write(path.join(dir, "outside.txt"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -366,7 +367,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -396,7 +397,7 @@ describe("tool.shell permissions", () => { test( `asks for nested PowerShell command permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -428,7 +429,7 @@ describe("tool.shell permissions", () => { `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -458,7 +459,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -489,7 +490,7 @@ describe("tool.shell permissions", () => { `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -519,7 +520,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -553,7 +554,7 @@ describe("tool.shell permissions", () => { const prev = process.env[key] delete process.env[key] try { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -588,7 +589,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -617,7 +618,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -649,7 +650,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -681,7 +682,7 @@ describe("tool.shell permissions", () => { test( `treats Set-Location like cd for permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -712,7 +713,7 @@ describe("tool.shell permissions", () => { test( `does not add nested PowerShell expressions to permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -741,7 +742,7 @@ describe("tool.shell permissions", () => { test( "asks for external_directory permission for cmd file commands [cmd]", withShell(cmdShell, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -766,7 +767,7 @@ describe("tool.shell permissions", () => { each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -791,7 +792,7 @@ describe("tool.shell permissions", () => { each("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -821,7 +822,7 @@ describe("tool.shell permissions", () => { const err = new Error("stop after permission") await using outerTmp = await tmpdir() await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -857,7 +858,7 @@ describe("tool.shell permissions", () => { test( "uses Git Bash /tmp semantics for external workdir", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -889,7 +890,7 @@ describe("tool.shell permissions", () => { test( "uses Git Bash /tmp semantics for external file paths", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -926,7 +927,7 @@ describe("tool.shell permissions", () => { }, }) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -959,7 +960,7 @@ describe("tool.shell permissions", () => { await Bun.write(path.join(dir, "tmpfile"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -981,7 +982,7 @@ describe("tool.shell permissions", () => { each("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1004,7 +1005,7 @@ describe("tool.shell permissions", () => { each("does not ask for bash permission when command is cd only", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -1026,7 +1027,7 @@ describe("tool.shell permissions", () => { each("matches redirects in permission pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -1049,7 +1050,7 @@ describe("tool.shell permissions", () => { each("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1065,7 +1066,7 @@ describe("tool.shell permissions", () => { describe("tool.shell abort", () => { test("preserves output when aborted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1099,7 +1100,7 @@ describe("tool.shell abort", () => { }, 15_000) test("terminates command on timeout", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1121,7 +1122,7 @@ describe("tool.shell abort", () => { }, 15_000) test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1142,7 +1143,7 @@ describe("tool.shell abort", () => { }) test("returns non-zero exit code", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1161,7 +1162,7 @@ describe("tool.shell abort", () => { }) test("streams metadata updates progressively", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -1192,7 +1193,7 @@ describe("tool.shell abort", () => { describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1214,7 +1215,7 @@ describe("tool.shell truncation", () => { }) test("truncates output exceeding byte limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1236,7 +1237,7 @@ describe("tool.shell truncation", () => { }) test("does not truncate small output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1256,7 +1257,7 @@ describe("tool.shell truncation", () => { }) test("full output is saved to file when truncated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index dbde4ed5b7..6c7f6aba77 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -41,7 +42,7 @@ describe("tool.webfetch", () => { await withFetch( () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) @@ -69,7 +70,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "image/svg+xml; charset=UTF-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) @@ -89,7 +90,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "text/plain; charset=utf-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) From 80f2b13a55035517860cc85d45b00634b5a4c7cd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:40:21 +0000 Subject: [PATCH 04/19] chore: generate --- .../opencode/src/project/with-instance.ts | 4 +- .../opencode/test/project/instance.test.ts | 1 - packages/sdk/js/src/v2/gen/types.gen.ts | 180 +++--- packages/sdk/openapi.json | 520 +++++++++--------- 4 files changed, 353 insertions(+), 352 deletions(-) diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts index b5b0e7c079..b7b5360c75 100644 --- a/packages/opencode/src/project/with-instance.ts +++ b/packages/opencode/src/project/with-instance.ts @@ -3,7 +3,9 @@ import { context } from "./instance-context" import { InstanceStore } from "./instance-store" export async function provide(input: { directory: string; fn: () => R }): Promise { - const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: input.directory }))) + const ctx = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory: input.directory })), + ) return context.provide(ctx, () => input.fn()) } diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 655e381b9a..99b0f0666b 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -236,5 +236,4 @@ describe("InstanceStore", () => { expect(() => Instance.current).toThrow() }), ) - }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index af29de17f2..31bd40ab4f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,35 +4,6 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } - sandboxes: Array -} - -export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} - export type EventServerInstanceDisposed = { type: "server.instance.disposed" properties: { @@ -40,6 +11,21 @@ export type EventServerInstanceDisposed = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -201,53 +187,6 @@ export type EventInstallationUpdateAvailable = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -463,6 +402,35 @@ export type EventCommandExecuted = { } } +export type Project = { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + time: { + created: number + updated: number + initialized?: number + } + sandboxes: Array +} + +export type EventProjectUpdated = { + type: "project.updated" + properties: Project +} + export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -470,6 +438,38 @@ export type EventVcsBranchUpdated = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -1111,8 +1111,9 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventProjectUpdated | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1122,12 +1123,6 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1142,7 +1137,12 @@ export type GlobalEvent = { | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2060,8 +2060,9 @@ export type File = { } export type Event = - | EventProjectUpdated | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2071,12 +2072,6 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2091,7 +2086,12 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 680771e18b..208346325b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7592,88 +7592,6 @@ }, "components": { "schemas": { - "Project": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { - "type": "string", - "const": "git" - }, - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } - }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "updated": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "initialized": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["created", "updated"] - }, - "sandboxes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["id", "worktree", "time", "sandboxes"] - }, - "Event.project.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "project.updated" - }, - "properties": { - "$ref": "#/components/schemas/Project" - } - }, - "required": ["type", "properties"] - }, "Event.server.instance.disposed": { "type": "object", "properties": { @@ -7693,6 +7611,48 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { @@ -8155,144 +8115,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -8793,6 +8615,88 @@ }, "required": ["type", "properties"] }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "const": "git" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + } + }, + "commands": { + "type": "object", + "properties": { + "start": { + "description": "Startup script to run when creating a new workspace (worktree)", + "type": "string" + } + } + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "initialized": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["created", "updated"] + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"] + }, + "Event.project.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "project.updated" + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["type", "properties"] + }, "Event.vcs.branch.updated": { "type": "object", "properties": { @@ -8811,6 +8715,102 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10958,10 +10958,13 @@ "payload": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -10990,24 +10993,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11050,9 +11035,24 @@ { "$ref": "#/components/schemas/Event.command.executed" }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13253,10 +13253,13 @@ "Event": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -13285,24 +13288,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13345,9 +13330,24 @@ { "$ref": "#/components/schemas/Event.command.executed" }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, From 68b3448b09fd72858d5ca7f01ade0dd29fa87adf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:42:09 -0400 Subject: [PATCH 05/19] refactor(cli): drop redundant explicit Effect.ensuring(store.dispose) (#25503) --- packages/opencode/src/cli/cmd/debug/agent.ts | 4 +- packages/opencode/src/cli/cmd/debug/config.ts | 11 +-- packages/opencode/src/cli/cmd/debug/file.ts | 47 +++---------- packages/opencode/src/cli/cmd/debug/lsp.ts | 43 ++++-------- .../opencode/src/cli/cmd/debug/ripgrep.ts | 66 ++++++++--------- packages/opencode/src/cli/cmd/debug/skill.ts | 13 +--- .../opencode/src/cli/cmd/debug/snapshot.ts | 29 ++------ packages/opencode/src/cli/cmd/export.ts | 7 +- packages/opencode/src/cli/cmd/import.ts | 7 +- packages/opencode/src/cli/cmd/session.ts | 70 ++++++++----------- packages/opencode/src/cli/cmd/stats.ts | 4 +- 11 files changed, 95 insertions(+), 206 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 831ca08b69..1a3f79396c 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -11,7 +11,6 @@ import { Permission } from "../../../permission" import { iife } from "../../../util/iife" import { effectCmd, fail } from "../../effect-cmd" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import type { InstanceContext } from "@/project/instance" export const AgentCommand = effectCmd({ @@ -35,8 +34,7 @@ export const AgentCommand = effectCmd({ handler: Effect.fn("Cli.debug.agent")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args, ctx).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args, ctx) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index 8102fcfb88..15bd1c1a92 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -2,20 +2,13 @@ import { EOL } from "os" import { Effect } from "effect" import { Config } from "@/config/config" import { effectCmd } from "../../effect-cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.config")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const config = yield* Config.Service.use((cfg) => cfg.get()) - process.stdout.write(JSON.stringify(config, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const config = yield* Config.Service.use((cfg) => cfg.get()) + process.stdout.write(JSON.stringify(config, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 1e2eb13bb7..d9bb252ea9 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -4,8 +4,6 @@ import { File } from "../../../file" import { Ripgrep } from "@/file/ripgrep" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" const FileSearchCommand = effectCmd({ command: "search ", @@ -17,13 +15,8 @@ const FileSearchCommand = effectCmd({ description: "Search query", }), handler: Effect.fn("Cli.debug.file.search")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) - process.stdout.write(results.join(EOL) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) + process.stdout.write(results.join(EOL) + EOL) }), }) @@ -37,13 +30,8 @@ const FileReadCommand = effectCmd({ description: "File path to read", }), handler: Effect.fn("Cli.debug.file.read")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const content = yield* File.Service.use((svc) => svc.read(args.path)) - process.stdout.write(JSON.stringify(content, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const content = yield* File.Service.use((svc) => svc.read(args.path)) + process.stdout.write(JSON.stringify(content, null, 2) + EOL) }), }) @@ -52,13 +40,8 @@ const FileStatusCommand = effectCmd({ describe: "show file status information", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.file.status")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const status = yield* File.Service.use((svc) => svc.status()) - process.stdout.write(JSON.stringify(status, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const status = yield* File.Service.use((svc) => svc.status()) + process.stdout.write(JSON.stringify(status, null, 2) + EOL) }), }) @@ -72,13 +55,8 @@ const FileListCommand = effectCmd({ description: "File path to list", }), handler: Effect.fn("Cli.debug.file.list")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const files = yield* File.Service.use((svc) => svc.list(args.path)) - process.stdout.write(JSON.stringify(files, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const files = yield* File.Service.use((svc) => svc.list(args.path)) + process.stdout.write(JSON.stringify(files, null, 2) + EOL) }), }) @@ -92,13 +70,8 @@ const FileTreeCommand = effectCmd({ default: process.cwd(), }), handler: Effect.fn("Cli.debug.file.tree")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) - console.log(JSON.stringify(tree, null, 2)) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + console.log(JSON.stringify(tree, null, 2)) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index b822a98bc1..b40b423181 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -4,8 +4,6 @@ import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const LSPCommand = cmd({ command: "lsp", @@ -20,18 +18,13 @@ const DiagnosticsCommand = effectCmd({ describe: "get diagnostics for a file", builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.touchFile(args.file, "full") - return yield* lsp.diagnostics() - }), - ) - process.stdout.write(JSON.stringify(out, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, "full") + return yield* lsp.diagnostics() + }), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) }), }) @@ -40,14 +33,9 @@ export const SymbolsCommand = effectCmd({ describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - using _ = Log.Default.time("symbols") - const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + using _ = Log.Default.time("symbols") + const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), }) @@ -56,13 +44,8 @@ export const DocumentSymbolsCommand = effectCmd({ describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - using _ = Log.Default.time("document-symbols") - const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + using _ = Log.Default.time("document-symbols") + const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index f0be704485..ca95c1d559 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -4,7 +4,6 @@ import { Ripgrep } from "../../../file/ripgrep" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const RipgrepCommand = cmd({ command: "rg", @@ -23,13 +22,10 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie( - Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), - ) - process.stdout.write(tree + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const tree = yield* Effect.orDie( + Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), + ) + process.stdout.write(tree + EOL) }), }) @@ -53,22 +49,19 @@ const FilesCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.files")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const rg = yield* Ripgrep.Service - const files = yield* rg - .files({ - cwd: ctx.directory, - glob: args.glob ? [args.glob] : undefined, - }) - .pipe( - Stream.take(args.limit ?? Infinity), - Stream.runCollect, - Effect.map((c) => [...c]), - Effect.orDie, - ) - process.stdout.write(files.join(EOL) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const rg = yield* Ripgrep.Service + const files = yield* rg + .files({ + cwd: ctx.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + Effect.orDie, + ) + process.stdout.write(files.join(EOL) + EOL) }), }) @@ -93,19 +86,16 @@ const SearchCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.search")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const results = yield* Effect.orDie( - Ripgrep.Service.use((svc) => - svc.search({ - cwd: ctx.directory, - pattern: args.pattern, - glob: args.glob as string[] | undefined, - limit: args.limit, - }), - ), - ) - process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const results = yield* Effect.orDie( + Ripgrep.Service.use((svc) => + svc.search({ + cwd: ctx.directory, + pattern: args.pattern, + glob: args.glob as string[] | undefined, + limit: args.limit, + }), + ), + ) + process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index e23410a69b..3b120da3cb 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -2,21 +2,14 @@ import { EOL } from "os" import { Effect } from "effect" import { Skill } from "../../../skill" import { effectCmd } from "../../effect-cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const SkillCommand = effectCmd({ command: "skill", describe: "list all available skills", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.skill")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const skill = yield* Skill.Service - const skills = yield* skill.all() - process.stdout.write(JSON.stringify(skills, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const skill = yield* Skill.Service + const skills = yield* skill.all() + process.stdout.write(JSON.stringify(skills, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts index 1675f175df..e37e63dc47 100644 --- a/packages/opencode/src/cli/cmd/debug/snapshot.ts +++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts @@ -2,8 +2,6 @@ import { Effect } from "effect" import { Snapshot } from "../../../snapshot" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const SnapshotCommand = cmd({ command: "snapshot", @@ -16,13 +14,8 @@ const TrackCommand = effectCmd({ command: "track", describe: "track current snapshot state", handler: Effect.fn("Cli.debug.snapshot.track")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.track()) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.track()) + console.log(out) }), }) @@ -36,13 +29,8 @@ const PatchCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) + console.log(out) }), }) @@ -56,12 +44,7 @@ const DiffCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) + console.log(out) }), }) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 5ff282b543..bf73ce941e 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -6,8 +6,6 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" import { Effect } from "effect" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" function redact(kind: string, id: string, value: string) { return value.trim() ? `[redacted:${kind}:${id}]` : value @@ -234,10 +232,7 @@ export const ExportCommand = effectCmd({ type: "boolean", }), handler: Effect.fn("Cli.export")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args) }), }) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 8d19376662..419e81379b 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -5,7 +5,6 @@ import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" @@ -88,13 +87,9 @@ export const ImportCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.import")(function* (args) { - // effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant. const ctx = yield* InstanceRef if (!ctx) return yield* Effect.die("InstanceRef not provided") - const store = yield* InstanceStore.Service - // Ensure store.dispose runs disposers and emits server.instance.disposed - // on every exit path: success, early return, typed failure, defect, interrupt. - return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* runImport(args.file, ctx.project.id) }), }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index dbf27ccc6c..08c0df929c 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -12,8 +12,6 @@ import { Process } from "@/util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -59,17 +57,12 @@ export const SessionDeleteCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.session.delete")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const svc = yield* Session.Service - const sessionID = SessionID.make(args.sessionID) - // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. - yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) - yield* svc.remove(sessionID) - UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const svc = yield* Session.Service + const sessionID = SessionID.make(args.sessionID) + // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. + yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) + yield* svc.remove(sessionID) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) }), }) @@ -90,39 +83,34 @@ export const SessionListCommand = effectCmd({ default: "table", }), handler: Effect.fn("Cli.session.list")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) + const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) - if (sessions.length === 0) return + if (sessions.length === 0) return - const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) + const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) - const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" + const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" - if (shouldPaginate) { - yield* Effect.promise(async () => { - const proc = Process.spawn(pagerCmd(), { - stdin: "pipe", - stdout: "inherit", - stderr: "inherit", - }) - - if (!proc.stdin) { - console.log(output) - return - } - - proc.stdin.write(output) - proc.stdin.end() - await proc.exited + if (shouldPaginate) { + yield* Effect.promise(async () => { + const proc = Process.spawn(pagerCmd(), { + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", }) - } else { - console.log(output) - } - }).pipe(Effect.ensuring(store.dispose(ctx))) + + if (!proc.stdin) { + console.log(output) + return + } + + proc.stdin.write(output) + proc.stdin.end() + await proc.exited + }) + } else { + console.log(output) + } }), }) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 966eb5f662..8bf7b2345c 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,7 +5,6 @@ import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { @@ -70,8 +69,7 @@ export const StatsCommand = effectCmd({ handler: Effect.fn("Cli.stats")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args, ctx.project).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args, ctx.project) }), }) From 9293cddb3a79e505e701ee173f98ebd84473b206 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:43:16 +0000 Subject: [PATCH 06/19] chore: generate --- packages/opencode/src/cli/cmd/debug/ripgrep.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index ca95c1d559..8d1cbd2b1e 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -22,9 +22,7 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const tree = yield* Effect.orDie( - Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), - ) + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) process.stdout.write(tree + EOL) }), }) From e709dc34fb795dfa35d49d67673baa7b0f56dac8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:43:23 -0400 Subject: [PATCH 07/19] feat: default HTTP API backend to on for dev/beta channels --- packages/core/src/flag/flag.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a3b8133b64..ed52f90e60 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,10 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels that default to the new effect-httpapi server backend. The legacy +// hono backend remains the default for stable (`prod`/`latest`) installs. +const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -81,7 +86,14 @@ export const Flag = { OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], - OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"), + // Defaults to true on dev/beta/local channels so internal users exercise the + // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay + // on the legacy hono backend until the rollout is complete. An explicit env + // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for + // stable users and an escape hatch for dev/beta users. + OPENCODE_EXPERIMENTAL_HTTPAPI: + truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || + (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), // Evaluated at access time (not module load) because tests, the CLI, and From e98c291866f4b3e48caa3dbeb39386dd884a45bd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 21:44:06 -0400 Subject: [PATCH 08/19] feat(cli): add instance: false opt-out to effectCmd (#25507) --- packages/opencode/src/cli/cmd/serve.ts | 19 ++++++----- packages/opencode/src/cli/effect-cmd.ts | 42 +++++++++++++++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 5f3211aa1c..a8a7234d9a 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,21 +1,24 @@ +import { Effect } from "effect" import { Server } from "../../server/server" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -export const ServeCommand = cmd({ +export const ServeCommand = effectCmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // need for an ambient project InstanceContext at startup. + instance: false, + handler: Effect.fn("Cli.serve")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 6785e0b612..94ad0232cf 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -18,6 +18,34 @@ export class CliError extends Schema.TaggedErrorClass()("CliError", { export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) +interface EffectCmdOpts { + command: string | readonly string[] + aliases?: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** + * Whether the command needs a project InstanceContext. Defaults to true. + * + * `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})` + * so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via + * `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy + * `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin + * init + LSP/File/etc forks) eagerly. + * + * `false`: skip the instance entirely. Saves the InstanceBootstrap work and + * suppresses the `server.instance.disposed` IPC event. The handler runs + * directly under AppRuntime — it can yield any `AppServices` but must not + * yield `InstanceRef` (it'd be undefined, causing a defect). + * + * Use `false` for commands that don't read project state (e.g. `models`, + * `serve`, `web`, `account`, `db`, `upgrade`). + */ + instance?: boolean + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: Args) => Effect.Effect +} + /** * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. @@ -35,15 +63,7 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's * `Command.make(...)` won't touch any handler bodies. */ -export const effectCmd = (opts: { - command: string | readonly string[] - aliases?: string | readonly string[] - describe: string | false - builder?: (yargs: Argv) => Argv - /** Defaults to process.cwd(). Override for commands that take a directory positional. */ - directory?: (args: Args) => string - handler: (args: Args) => Effect.Effect -}) => +export const effectCmd = (opts: EffectCmdOpts) => cmd<{}, Args>({ command: opts.command, aliases: opts.aliases, @@ -52,6 +72,10 @@ export const effectCmd = (opts: { async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args + if (opts.instance === false) { + await AppRuntime.runPromise(opts.handler(args)) + return + } const directory = opts.directory?.(args) ?? process.cwd() await AppRuntime.runPromise( InstanceStore.Service.use((store) => From 1409a0715cd9f0bd92b9c1b736055791f336324c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 21:59:35 -0400 Subject: [PATCH 09/19] refactor(cli): convert web + account to effectCmd (instance: false) (#25512) --- packages/opencode/src/cli/cmd/account.ts | 47 +++++++++++++----------- packages/opencode/src/cli/cmd/web.ts | 19 ++++++---- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 38c28032cd..e0755577b6 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { Account } from "@/account/account" import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd } from "../effect-cmd" import * as Prompt from "../effect/prompt" import open from "open" @@ -172,60 +172,65 @@ const openEffect = Effect.fn("open")(function* () { yield* Prompt.outro("Opened " + url) }) -export const LoginCommand = cmd({ +export const LoginCommand = effectCmd({ command: "login ", describe: false, + instance: false, builder: (yargs) => yargs.positional("url", { describe: "server URL", type: "string", demandOption: true, }), - async handler(args) { + handler: Effect.fn("Cli.account.login")(function* (args) { UI.empty() - await AppRuntime.runPromise(loginEffect(args.url)) - }, + yield* Effect.orDie(loginEffect(args.url)) + }), }) -export const LogoutCommand = cmd({ +export const LogoutCommand = effectCmd({ command: "logout [email]", describe: false, + instance: false, builder: (yargs) => yargs.positional("email", { describe: "account email to log out from", type: "string", }), - async handler(args) { + handler: Effect.fn("Cli.account.logout")(function* (args) { UI.empty() - await AppRuntime.runPromise(logoutEffect(args.email)) - }, + yield* Effect.orDie(logoutEffect(args.email)) + }), }) -export const SwitchCommand = cmd({ +export const SwitchCommand = effectCmd({ command: "switch", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.switch")(function* () { UI.empty() - await AppRuntime.runPromise(switchEffect()) - }, + yield* Effect.orDie(switchEffect()) + }), }) -export const OrgsCommand = cmd({ +export const OrgsCommand = effectCmd({ command: "orgs", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.orgs")(function* () { UI.empty() - await AppRuntime.runPromise(orgsEffect()) - }, + yield* Effect.orDie(orgsEffect()) + }), }) -export const OpenCommand = cmd({ +export const OpenCommand = effectCmd({ command: "open", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.open")(function* () { UI.empty() - await AppRuntime.runPromise(openEffect()) - }, + yield* Effect.orDie(openEffect()) + }), }) export const ConsoleCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 19ee38ff53..f20381a014 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,7 @@ +import { Effect } from "effect" import { Server } from "../../server/server" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import open from "open" @@ -28,16 +29,19 @@ function getNetworkIPs() { return results } -export const WebCommand = cmd({ +export const WebCommand = effectCmd({ command: "web", builder: (yargs) => withNetworkOptions(yargs), describe: "start opencode server and open web interface", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // ambient project InstanceContext needed at startup. + instance: false, + handler: Effect.fn("Cli.web")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) UI.empty() UI.println(UI.logo(" ")) UI.empty() @@ -75,7 +79,6 @@ export const WebCommand = cmd({ open(displayUrl).catch(() => {}) } - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) From a3bc5d35b0f8f542d4531193b8816bc8b55363e3 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 2 May 2026 22:09:48 -0400 Subject: [PATCH 10/19] Refactor v2 session events as schemas (#24512) --- packages/core/src/flag/flag.ts | 1 + packages/core/src/util/log.ts | 2 + .../migration.sql | 17 + .../snapshot.json | 1481 +++++ .../snapshot.json | 176 +- .../20260501142318_next_venus/migration.sql | 2 + .../20260501142318_next_venus/snapshot.json | 1511 +++++ packages/opencode/src/bus/bus-event.ts | 2 + packages/opencode/src/bus/global.ts | 14 +- packages/opencode/src/bus/index.ts | 25 +- packages/opencode/src/cli/cmd/tui/app.tsx | 45 +- .../cli/cmd/tui/component/prompt/index.tsx | 12 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 298 + .../tui/feature-plugins/system/session-v2.tsx | 1087 +++ .../src/cli/cmd/tui/plugin/internal.ts | 3 + packages/opencode/src/server/routes/global.ts | 3 + .../src/server/routes/instance/event.ts | 8 +- .../src/server/routes/instance/httpapi/api.ts | 2 + .../server/routes/instance/httpapi/event.ts | 4 +- .../routes/instance/httpapi/groups/v2.ts | 14 + .../instance/httpapi/groups/v2/message.ts | 69 + .../instance/httpapi/groups/v2/session.ts | 140 + .../instance/httpapi/handlers/global.ts | 5 +- .../routes/instance/httpapi/handlers/v2.ts | 6 + .../instance/httpapi/handlers/v2/message.ts | 60 + .../instance/httpapi/handlers/v2/session.ts | 115 + .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/index.ts | 128 +- packages/opencode/src/session/compaction.ts | 24 +- packages/opencode/src/session/processor.ts | 143 +- .../opencode/src/session/projectors-next.ts | 204 + packages/opencode/src/session/projectors.ts | 5 +- packages/opencode/src/session/prompt.ts | 119 +- packages/opencode/src/session/session.sql.ts | 25 +- packages/opencode/src/session/session.ts | 29 + packages/opencode/src/sync/index.ts | 8 +- packages/opencode/src/util/effect-zod.ts | 2 +- packages/opencode/src/v2/event.ts | 53 + packages/opencode/src/v2/schema.ts | 10 + .../opencode/src/v2/session-entry-stepper.ts | 261 - packages/opencode/src/v2/session-entry.ts | 220 - packages/opencode/src/v2/session-event.ts | 687 +- .../src/v2/session-message-updater.ts | 411 ++ packages/opencode/src/v2/session-message.ts | 178 + packages/opencode/src/v2/session-prompt.ts | 36 + packages/opencode/src/v2/session.ts | 302 +- packages/opencode/src/v2/tool-output.ts | 18 + .../test/acp/event-subscription.test.ts | 1 + .../opencode/test/cli/tui/use-event.test.tsx | 2 + packages/opencode/test/preload.ts | 3 +- .../test/server/httpapi-bridge.test.ts | 9 +- .../test/server/httpapi-event.test.ts | 15 +- .../test/server/httpapi-session.test.ts | 43 +- .../opencode/test/session/compaction.test.ts | 10 + packages/opencode/test/session/prompt.test.ts | 44 + .../session/session-entry-stepper.test.ts | 916 --- packages/opencode/test/sync/index.test.ts | 2 +- .../test/v2/session-message-updater.test.ts | 203 + packages/sdk/js/script/build.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 3615 +++++----- packages/sdk/js/src/v2/gen/types.gen.ts | 5853 ++++++++++------- specs/v2/session-concepts-gap.md | 131 + 62 files changed, 12801 insertions(+), 6015 deletions(-) create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/migration.sql create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json create mode 100644 packages/opencode/migration/20260501142318_next_venus/migration.sql create mode 100644 packages/opencode/migration/20260501142318_next_venus/snapshot.json create mode 100644 packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts create mode 100644 packages/opencode/src/session/projectors-next.ts create mode 100644 packages/opencode/src/v2/event.ts create mode 100644 packages/opencode/src/v2/schema.ts delete mode 100644 packages/opencode/src/v2/session-entry-stepper.ts delete mode 100644 packages/opencode/src/v2/session-entry.ts create mode 100644 packages/opencode/src/v2/session-message-updater.ts create mode 100644 packages/opencode/src/v2/session-message.ts create mode 100644 packages/opencode/src/v2/session-prompt.ts create mode 100644 packages/opencode/src/v2/tool-output.ts delete mode 100644 packages/opencode/test/session/session-entry-stepper.test.ts create mode 100644 packages/opencode/test/v2/session-message-updater.test.ts create mode 100644 specs/v2/session-concepts-gap.md diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ed52f90e60..0daae55800 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -95,6 +95,7 @@ export const Flag = { truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), + OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index a61c15f7a7..e1962aed4c 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -1,3 +1,5 @@ +export * as Log from "./log" + import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql new file mode 100644 index 0000000000..d5efe5f9e8 --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `session_message` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `type` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + `data` text NOT NULL, + CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint +CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint +CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint +CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint +DROP TABLE `session_entry`; \ No newline at end of file diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json new file mode 100644 index 0000000000..bb6d06237e --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -0,0 +1,1481 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "61f807f9-6398-4067-be05-804acc2561bc", + "prevIds": [ + "66cbe0d7-def0-451b-b88a-7608513a9b44" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index d79324fedf..1f3bc493c1 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,7 +2,9 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "prevIds": [ + "61f807f9-6398-4067-be05-804acc2561bc" + ], "ddl": [ { "name": "account_state", @@ -37,7 +39,7 @@ "entityType": "tables" }, { - "name": "session_entry", + "name": "session_message", "entityType": "tables" }, { @@ -598,7 +600,7 @@ "generated": null, "name": "id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -608,7 +610,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -618,7 +620,7 @@ "generated": null, "name": "type", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -628,7 +630,7 @@ "generated": null, "name": "time_created", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -638,7 +640,7 @@ "generated": null, "name": "time_updated", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -648,7 +650,7 @@ "generated": null, "name": "data", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -1051,9 +1053,13 @@ "table": "event" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1062,9 +1068,13 @@ "table": "account_state" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1073,9 +1083,13 @@ "table": "workspace" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1084,9 +1098,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1095,9 +1113,13 @@ "table": "part" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1106,20 +1128,28 @@ "table": "permission" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, - "name": "fk_session_entry_session_id_session_id_fk", + "name": "fk_session_message_session_id_session_id_fk", "entityType": "fks", - "table": "session_entry" + "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1128,9 +1158,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1139,9 +1173,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1150,9 +1188,13 @@ "table": "session_share" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1161,98 +1203,128 @@ "table": "event" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, - "name": "session_entry_pk", - "table": "session_entry", + "name": "session_message_pk", + "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1322,9 +1394,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_idx", + "name": "session_message_session_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1340,9 +1412,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_type_idx", + "name": "session_message_session_type_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1354,9 +1426,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_time_created_idx", + "name": "session_message_time_created_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ diff --git a/packages/opencode/migration/20260501142318_next_venus/migration.sql b/packages/opencode/migration/20260501142318_next_venus/migration.sql new file mode 100644 index 0000000000..e0ffe7823c --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint +ALTER TABLE `session` ADD `model` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json new file mode 100644 index 0000000000..e594de2f04 --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -0,0 +1,1511 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", + "prevIds": [ + "aaa2ebeb-caa4-478d-8365-4fc595d16856" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index cf9fcfbeec..3250c166ab 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -24,6 +24,7 @@ export function payloads() { .map(([type, def]) => { return z .object({ + id: z.string(), type: z.literal(type), properties: zodObject(def.properties), }) @@ -39,6 +40,7 @@ export function effectPayloads() { .entries() .map(([type, def]) => Schema.Struct({ + id: Schema.String, type: Schema.Literal(type), properties: def.properties, }).annotate({ identifier: `Event.${type}` }), diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index b5392a81b9..3cfd453624 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "events" +import { Identifier } from "@/id/id" export type GlobalEvent = { directory?: string @@ -7,6 +8,15 @@ export type GlobalEvent = { payload: any } -export const GlobalBus = new EventEmitter<{ +class GlobalBusEmitter extends EventEmitter<{ event: [GlobalEvent] -}>() +}> { + override emit(eventName: "event", event: GlobalEvent): boolean { + if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { + event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") + } + return super.emit(eventName, event) + } +} + +export const GlobalBus = new GlobalBusEmitter() diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 9ee8e6fb03..449694a53a 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,6 +5,7 @@ import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { Identifier } from "@/id/id" const log = Log.create({ service: "bus" }) @@ -18,6 +19,7 @@ export const InstanceDisposed = BusEvent.define( ) type Payload = { + id: string type: D["type"] properties: BusProperties } @@ -28,7 +30,11 @@ type State = { } export interface Interface { - readonly publish: (def: D, properties: BusProperties) => Effect.Effect + readonly publish: ( + def: D, + properties: BusProperties, + options?: { id?: string }, + ) => Effect.Effect readonly subscribe: (def: D) => Stream.Stream> readonly subscribeAll: () => Stream.Stream readonly subscribeCallback: ( @@ -53,6 +59,7 @@ export const layer = Layer.effect( // Publish InstanceDisposed before shutting down so subscribers see it yield* PubSub.publish(wildcard, { type: InstanceDisposed.type, + id: createID(), properties: { directory: ctx.directory }, }) yield* PubSub.shutdown(wildcard) @@ -77,10 +84,10 @@ export const layer = Layer.effect( }) } - function publish(def: D, properties: BusProperties) { + function publish(def: D, properties: BusProperties, options?: { id?: string }) { return Effect.gen(function* () { const s = yield* InstanceState.get(state) - const payload: Payload = { type: def.type, properties } + const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties } log.info("publishing", { type: def.type }) const ps = s.typed.get(def.type) @@ -173,8 +180,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer) // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. -export async function publish(def: D, properties: BusProperties) { - return runPromise((svc) => svc.publish(def, properties)) +export function createID() { + return Identifier.create("evt", "ascending") +} + +export async function publish( + def: D, + properties: BusProperties, + options?: { id?: string }, +) { + return runPromise((svc) => svc.publish(def, properties, options)) } export function subscribe(def: D, callback: (event: Payload) => unknown) { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7117ae7d1b..ea742f6997 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -28,6 +28,7 @@ import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" +import { SyncProviderV2 } from "@tui/context/sync-v2" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" import { useConnected } from "@tui/component/use-connected" @@ -168,27 +169,29 @@ export function tui(input: { > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 79034a01bb..a6ba797f33 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -750,9 +750,18 @@ export function Prompt(props: PromptProps) { return false } + const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { - const res = await sdk.client.session.create({ workspace: props.workspaceID }) + const res = await sdk.client.session.create({ + workspace: props.workspaceID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) if (res.error) { console.log("Creating a session failed:", res.error) @@ -792,7 +801,6 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode - const variant = local.model.variant.current() const editorSelection = editorContext() const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx new file mode 100644 index 0000000000..f82bb4d962 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -0,0 +1,298 @@ +import { useEvent } from "@tui/context/event" +import type { + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, +} from "@opencode-ai/sdk/v2" +import { createStore, produce, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +function activeAssistant(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + if (index < 0) return + const assistant = messages[index] + return assistant?.type === "assistant" ? assistant : undefined +} + +function activeCompaction(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "compaction") + if (index < 0) return + const compaction = messages[index] + return compaction?.type === "compaction" ? compaction : undefined +} + +function activeShell(messages: SessionMessage[], callID: string) { + const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + if (index < 0) return + const shell = messages[index] + return shell?.type === "shell" ? shell : undefined +} + +function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) +} + +function latestText(assistant: SessionMessageAssistant | undefined) { + return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") +} + +function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + ) +} + +export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ + name: "SyncV2", + init: () => { + const [store, setStore] = createStore<{ + messages: { + [sessionID: string]: SessionMessage[] + } + }>({ + messages: {}, + }) + + const event = useEvent() + const sdk = useSDK() + + function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + setStore( + "messages", + produce((draft) => { + fn((draft[sessionID] ??= [])) + }), + ) + } + + event.subscribe((event) => { + switch (event.type) { + case "session.next.prompted": { + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + time: { created: event.properties.timestamp }, + }) + }) + break + } + case "session.next.synthetic": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "synthetic", + sessionID: event.properties.sessionID, + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "shell", + callID: event.properties.callID, + command: event.properties.command, + output: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.ended": + update(event.properties.sessionID, (draft) => { + const match = activeShell(draft, event.properties.callID) + if (!match) return + match.output = event.properties.output + match.time.completed = event.properties.timestamp + }) + break + case "session.next.step.started": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + draft.push({ + id: event.id, + type: "assistant", + agent: event.properties.agent, + model: event.properties.model, + content: [], + snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.step.ended": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = event.properties.finish + currentAssistant.cost = event.properties.cost + currentAssistant.tokens = event.properties.tokens + if (event.properties.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + }) + break + case "session.next.text.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ type: "text", text: "" }) + }) + break + case "session.next.text.delta": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text += event.properties.delta + }) + break + case "session.next.text.ended": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text = event.properties.text + }) + break + case "session.next.tool.input.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "tool", + id: event.properties.callID, + name: event.properties.name, + time: { created: event.properties.timestamp }, + state: { status: "pending", input: "" }, + }) + }) + break + case "session.next.tool.input.delta": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status === "pending") match.state.input += event.properties.delta + }) + break + case "session.next.tool.input.ended": + break + case "session.next.tool.called": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (!match) return + match.time.ran = event.properties.timestamp + match.provider = event.properties.provider + match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + }) + break + case "session.next.tool.progress": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state.structured = event.properties.structured + match.state.content = [...event.properties.content] + }) + break + case "session.next.tool.success": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "completed", + input: match.state.input, + structured: event.properties.structured, + content: [...event.properties.content], + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.tool.error": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "error", + error: event.properties.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.reasoning.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "reasoning", + id: event.properties.reasoningID, + text: "", + }) + }) + break + case "session.next.reasoning.delta": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text += event.properties.delta + }) + break + case "session.next.reasoning.ended": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text = event.properties.text + }) + break + case "session.next.retried": + break + case "session.next.compaction.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "compaction", + reason: event.properties.reason, + summary: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.compaction.delta": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (match) match.summary += event.properties.text + }) + break + case "session.next.compaction.ended": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (!match) return + match.summary = event.properties.text + match.include = event.properties.include + }) + break + } + }) + + const result = { + data: store, + session: { + message: { + async sync(sessionID: string) { + const response = await sdk.client.v2.session.messages({ sessionID }) + setStore("messages", sessionID, reconcile(response.data?.items ?? [])) + }, + fromSession(sessionID: string) { + const messages = store.messages[sessionID] + if (!messages) return [] + return messages + }, + }, + }, + } + + return result + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx new file mode 100644 index 0000000000..7270a9c3b7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -0,0 +1,1087 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { useSyncV2 } from "@tui/context/sync-v2" +import { SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" +import { useTheme } from "@tui/context/theme" +import { useLocal } from "@tui/context/local" +import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import type { SyntaxStyle } from "@opentui/core" +import { Locale } from "@/util/locale" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import path from "path" +import stripAnsi from "strip-ansi" +import type { + SessionMessage, + SessionMessageAgentSwitched, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionMessageCompaction, + SessionMessageModelSwitched, + SessionMessageShell, + SessionMessageSynthetic, + SessionMessageUser, + ToolFileContent, + ToolTextContent, +} from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" + +const id = "internal:session-v2-debug" +const route = "session.v2.messages" + +function currentSessionID(api: TuiPluginApi) { + const current = api.route.current + if (current.name !== "session") return + const sessionID = current.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +function View(props: { api: TuiPluginApi; sessionID: string }) { + const sync = useSyncV2() + const dimensions = useTerminalDimensions() + const { theme, syntax, subtleSyntax } = useTheme() + const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) + const renderedMessages = createMemo(() => messages().toReversed()) + const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + + createEffect(() => { + void sync.session.message.sync(props.sessionID) + }) + + useKeyboard((event) => { + if (event.name !== "escape") return + event.preventDefault() + event.stopPropagation() + props.api.route.navigate("session", { sessionID: props.sessionID }) + }) + + return ( + + + + + + + + + + {(message, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + ) +} + +function MissingData(props: { label: string; detail: string }) { + const { theme } = useTheme() + return ( + + + MISSING DATA {props.label} + + {props.detail} + + ) +} + +function UserMessage(props: { message: SessionMessageUser; index: number }) { + const { theme } = useTheme() + const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])]) + return ( + + + + } + > + {props.message.text} + + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + ) +} + +function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { + const { theme } = useTheme() + return ( + + Synthetic + {props.message.text} + + ) +} + +function ShellMessage(props: { message: SessionMessageShell }) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi(props.message.output.trim())) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + setExpanded((prev) => !prev) : undefined} + > + + $ {props.message.command} + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + ) +} + +function CompactionMessage(props: { message: SessionMessageCompaction }) { + const { theme } = useTheme() + return ( + + + {props.message.summary} + + + ) +} + +function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) { + const { theme } = useTheme() + const local = useLocal() + return ( + + + + Switched agent to + {Locale.titlecase(props.message.agent)} + + + ) +} + +function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) { + const { theme } = useTheme() + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + return ( + + + + Switched model to + {model()} + + + ) +} + +function UnknownMessage(props: { message: SessionMessage }) { + return +} + +function AssistantMessage(props: { + message: SessionMessageAssistant + last: boolean + syntax: SyntaxStyle + subtleSyntax: SyntaxStyle +}) { + const { theme } = useTheme() + const local = useLocal() + const duration = createMemo(() => { + if (!props.message.time.completed) return 0 + return props.message.time.completed - props.message.time.created + }) + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)) + return ( + <> + + {(part) => ( + + + + + + + + + + + + )} + + + + + + + {props.message.error} + + + + + + + {Locale.titlecase(props.message.agent)} + · {model()} + + · {Locale.duration(duration())} + + + + + + ) +} + +function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) { + const { theme } = useTheme() + return ( + + + + + + ) +} + +function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) { + const { theme } = useTheme() + const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) + return ( + + + + + + ) +} + +function AssistantTool(props: { part: SessionMessageAssistantTool }) { + const input = createMemo(() => toolInputRecord(props.part.state.input)) + const toolprops = { + get input() { + return input() + }, + get metadata() { + return props.part.provider?.metadata ?? {} + }, + get output() { + return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) + }, + part: props.part, + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +type ToolProps = { + input: Record + metadata: Record + output?: string + part: SessionMessageAssistantTool +} + +function GenericTool(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => props.output?.trim() ?? "") + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const maxLines = 3 + const overflow = createMemo(() => lines().length > maxLines) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, maxLines), "…"].join("\n") + }) + return ( + + {props.part.name} {input(props.input)} + + } + > + setExpanded((prev) => !prev) : undefined} + > + + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + ) +} + +function InlineTool(props: { + icon: string + complete: unknown + pending: string + spinner?: boolean + children: JSX.Element + part: SessionMessageAssistantTool +}) { + const { theme } = useTheme() + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const denied = createMemo(() => { + const message = error() + if (!message) return false + return ( + message.includes("QuestionRejectedError") || + message.includes("rejected permission") || + message.includes("user dismissed") + ) + }) + return ( + + + + {props.children} + + + + ~ {props.pending}} when={props.complete}> + {props.icon} {props.children} + + + + + + {error()} + + + ) +} + +function BlockTool(props: { + title: string + children: JSX.Element + part?: SessionMessageAssistantTool + onClick?: () => void + spinner?: boolean +}) { + const { theme } = useTheme() + const renderer = useRenderer() + const [hover, setHover] = createSignal(false) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) + return ( + props.onClick && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick?.() + }} + flexShrink={0} + > + + {props.title} + + } + > + {props.title.replace(/^# /, "")} + + {props.children} + + {error()} + + + ) +} + +function Bash(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) + const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) + const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + + + setExpanded((prev) => !prev) : undefined} + > + + $ {command()} + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + + + {command()} + + + + ) +} + +function Glob(props: ToolProps) { + return ( + + Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(count) => ( + <> + ({count()} {count() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function Read(props: ToolProps) { + const { theme } = useTheme() + const loaded = createMemo(() => + arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"), + ) + return ( + <> + + Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "} + {input(props.input, ["filePath"])} + + + {(filepath) => ( + + + ↳ Loaded {normalizePath(filepath)} + + + )} + + + ) +} + +function Grep(props: ToolProps) { + return ( + + Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(matches) => ( + <> + ({matches()} {matches() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function WebFetch(props: ToolProps) { + return ( + + WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)} + + ) +} + +function CodeSearch(props: ToolProps) { + return ( + + Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function WebSearch(props: ToolProps) { + return ( + + Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function Write(props: ToolProps) { + const { theme, syntax } = useTheme() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const content = createMemo(() => stringValue(props.input.content) ?? "") + return ( + + + + + + + + + + + + Write {normalizePath(filePath())} + + + + ) +} + +function Edit(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const diff = createMemo(() => stringValue(props.metadata.diff)) + return ( + + + {(diff) => ( + + + 120 ? "split" : "unified"} + filetype={filetype(filePath())} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + + + )} + + + + Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} + + + + ) +} + +function ApplyPatch(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : []))) + const fileTitle = (file: Record) => { + const type = stringValue(file.type) + const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch" + if (type === "delete") return "# Deleted " + relativePath + if (type === "add") return "# Created " + relativePath + if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath + return "← Patched " + relativePath + } + return ( + + 0}> + + {(file) => ( + + + -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"} + + } + > + {(patch) => ( + + 120 ? "split" : "unified"} + filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + )} + + + )} + + + + + Patch + + + + ) +} + +function TodoWrite(props: ToolProps) { + const { theme } = useTheme() + const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : []))) + return ( + + 0 && props.part.state.status === "completed"}> + + + + {(todo) => ( + + {todoIcon(stringValue(todo.status))} {stringValue(todo.content)} + + )} + + + + + + + Updating todos... + + + + ) +} + +function Question(props: ToolProps) { + const { theme } = useTheme() + const questions = createMemo(() => + arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), + ) + const answers = createMemo(() => arrayValue(props.metadata.answers)) + return ( + + 0}> + + + + {(question, index) => ( + + {stringValue(question.question)} + {formatAnswer(answers()[index()])} + + )} + + + + + + + Asked {questions().length} question{questions().length === 1 ? "" : "s"} + + + + ) +} + +function Skill(props: ToolProps) { + return ( + + Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" + + ) +} + +function Task(props: ToolProps) { + const content = createMemo(() => { + const description = stringValue(props.input.description) + if (!description) return pendingInput(props.part) + return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}` + }) + return ( + + {content()} + + ) +} + +function Diagnostics(props: { diagnostics: unknown; filePath: string }) { + const { theme } = useTheme() + const errors = createMemo(() => { + if (!isRecord(props.diagnostics)) return [] + const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath] + return arrayValue(value) + .flatMap((item) => (isRecord(item) ? [item] : [])) + .filter((diagnostic) => diagnostic.severity === 1) + .slice(0, 3) + }) + return ( + + + + {(diagnostic) => Error {stringValue(diagnostic.message)}} + + + + ) +} + +function toolOutput(content?: Array) { + return (content ?? []) + .map((item) => { + if (item.type === "text") return item.text.trim() + return `[file ${item.name ?? item.uri}]` + }) + .filter(Boolean) + .join("\n") +} + +function toolInputRecord(input: string | Record) { + if (typeof input === "string") return {} + return input +} + +function pendingInput(part: SessionMessageAssistantTool) { + if (part.state.status !== "pending") return "" + return part.state.input.trim() +} + +function toolComplete(part: SessionMessageAssistantTool) { + if (part.state.status === "pending") return pendingInput(part) + return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function numberValue(value: unknown) { + return typeof value === "number" ? value : undefined +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function input(input: Record, omit?: string[]) { + const primitives = Object.entries(input).filter(([key, value]) => { + if (omit?.includes(key)) return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + if (primitives.length === 0) return "" + return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` +} + +function normalizePath(input?: string) { + if (!input) return "" + const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) + const relative = path.relative(process.cwd(), absolute) + if (!relative) return "." + if (!relative.startsWith("..")) return relative + return absolute +} + +function filetype(input?: string) { + if (!input) return "none" + const language = LANGUAGE_EXTENSIONS[path.extname(input)] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function todoIcon(status?: string) { + if (status === "completed") return "✓" + if (status === "in_progress") return "~" + if (status === "cancelled") return "✕" + return "☐" +} + +function formatAnswer(answer: unknown) { + if (!Array.isArray(answer)) return "(no answer)" + if (answer.length === 0) return "(no answer)" + return answer.filter((item): item is string => typeof item === "string").join(", ") +} + +const tui: TuiPlugin = async (api) => { + api.route.register([ + { + name: route, + render(input) { + const sessionID = input.params?.sessionID + if (typeof sessionID !== "string") { + return Missing sessionID + } + return + }, + }, + ]) + + api.command.register(() => [ + { + title: "View v2 session messages", + value: route, + category: "Debug", + suggested: api.route.current.name === "session", + enabled: api.route.current.name === "session", + onSelect() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, + }, + ]) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 856ee0ebb1..2b0d859192 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -7,7 +7,9 @@ import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" +import SessionV2Debug from "../feature-plugins/system/session-v2" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { Flag } from "@opencode-ai/core/flag/flag" export type InternalTuiPlugin = TuiPluginModule & { id: string @@ -24,4 +26,5 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarFiles, SidebarFooter, PluginManager, + ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), ] diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 4a491d95b6..da3614d228 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -6,6 +6,7 @@ import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" +import { Bus } from "@/bus" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { Installation } from "@/installation" @@ -26,6 +27,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.connected", properties: {}, }, @@ -37,6 +39,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.heartbeat", properties: {}, }, diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 474d92b31b..52e9bc1964 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -42,6 +42,7 @@ export const EventRoutes = () => q.push( JSON.stringify({ + id: Bus.createID(), type: "server.connected", properties: {}, }), @@ -50,9 +51,10 @@ export const EventRoutes = () => // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { q.push( - JSON.stringify({ - type: "server.heartbeat", - properties: {}, + JSON.stringify({ + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, }), ) }, 10_000) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 81ea2394c0..1cf1584e3e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -19,6 +19,7 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" +import { V2Api } from "./groups/v2" // SSE event schemas built from the same BusEvent/SyncEvent registries that // the Hono spec uses, so both specs emit identical Event/SyncEvent components. @@ -40,6 +41,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ProviderApi) .addHttpApi(SessionApi) .addHttpApi(SyncApi) + .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 25e810753e..a5c328ac0e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -41,12 +41,12 @@ function eventResponse(bus: Bus.Interface) { const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })), ) log.info("event connected") return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts new file mode 100644 index 0000000000..05da5b720d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -0,0 +1,14 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { MessageGroup } from "./v2/message" +import { SessionGroup } from "./v2/session" + +export const V2Api = HttpApi.make("v2") + .add(SessionGroup) + .add(MessageGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts new file mode 100644 index 0000000000..3b0b2fa5b1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -0,0 +1,69 @@ +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const MessageGroup = HttpApiGroup.make("v2.message") + .add( + HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { + params: { sessionID: SessionID }, + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionMessagesQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionMessagesResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get v2 session messages", + description: + "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 messages", + description: "Experimental v2 message routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts new file mode 100644 index 0000000000..17ddcaeda3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -0,0 +1,140 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Prompt } from "@/v2/session-prompt" +import { SessionV2 } from "@/v2/session" +import { Schema, SchemaGetter } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const SessionGroup = HttpApiGroup.make("v2.session") + .add( + HttpApiEndpoint.get("sessions", "/api/session", { + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspace: WorkspaceID.pipe(Schema.optional), + roots: Schema.Literals(["true", "false"]) + .pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), + ) + .pipe(Schema.optional), + start: Schema.NumberFromString.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + directory: Schema.optional(Schema.Never), + path: Schema.optional(Schema.Never), + workspace: Schema.optional(Schema.Never), + roots: Schema.optional(Schema.Never), + start: Schema.optional(Schema.Never), + search: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionsQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionV2.Info), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionsResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List v2 sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ + prompt: Prompt, + delivery: SessionV2.Delivery.pipe(Schema.optional), + }), + success: SessionMessage.Message, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send v2 message", + description: "Create a v2 session message and queue it for the agent loop.", + }), + ), + ) + .add( + HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact v2 session", + description: "Compact a v2 session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for v2 session", + description: "Wait for a v2 session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { + params: { sessionID: SessionID }, + success: Schema.Array(SessionMessage.Message), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get v2 session context", + description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2", + description: "Experimental v2 routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index f9be57f4fd..f80869b64d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { EffectBridge } from "@/effect/bridge" +import { Bus } from "@/bus" import { Installation } from "@/installation" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -43,11 +44,11 @@ function eventResponse() { }) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })), ) return HttpServerResponse.stream( - Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts new file mode 100644 index 0000000000..55cb534581 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -0,0 +1,6 @@ +import { SessionV2 } from "@/v2/session" +import { Layer } from "effect" +import { messageHandlers } from "./v2/message" +import { sessionHandlers } from "./v2/session" + +export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts new file mode 100644 index 0000000000..3485d80fd6 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -0,0 +1,60 @@ +import { SessionMessage } from "@/v2/session-message" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import * as DateTime from "effect/DateTime" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultMessagesLimit = 50 + +const Cursor = Schema.Struct({ + id: SessionMessage.ID, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), +}) + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +const cursor = { + encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + return Buffer.from( + JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers.handle( + "messages", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const messages = yield* session.messages({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit ?? DefaultMessagesLimit, + order, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = messages[0] + const last = messages.at(-1) + return { + items: messages, + cursor: { + previous: first ? cursor.encode(first, order, "previous") : undefined, + next: last ? cursor.encode(last, order, "next") : undefined, + }, + } + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts new file mode 100644 index 0000000000..558e34dd18 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -0,0 +1,115 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultSessionsLimit = 50 + +const SessionCursor = Schema.Struct({ + id: SessionV2.Info.fields.id, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspaceID: WorkspaceID.pipe(Schema.optional), + roots: Schema.Boolean.pipe(Schema.optional), + start: Schema.Finite.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), +}) +type SessionCursor = typeof SessionCursor.Type + +const decodeCursor = Schema.decodeUnknownSync(SessionCursor) + +const sessionCursor = { + encode( + session: SessionV2.Info, + order: "asc" | "desc", + direction: "previous" | "next", + filters: Pick, + ) { + return Buffer.from( + JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers + .handle( + "sessions", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const filters = decoded ?? { + directory: ctx.query.directory, + path: ctx.query.path, + workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined, + roots: ctx.query.roots, + start: ctx.query.start, + search: ctx.query.search, + } + const sessions = yield* session.list({ + limit: ctx.query.limit ?? DefaultSessionsLimit, + order, + directory: filters.directory, + path: filters.path, + workspaceID: filters.workspaceID, + roots: filters.roots, + start: filters.start, + search: filters.search, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + items: sessions, + cursor: { + previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined, + next: last ? sessionCursor.encode(last, order, "next", filters) : undefined, + }, + } + }), + ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return yield* session.prompt({ + sessionID: ctx.params.sessionID, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, + }) + }), + ) + .handle( + "compact", + Effect.fn(function* (ctx) { + yield* session.compact(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "wait", + Effect.fn(function* (ctx) { + yield* session.wait(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "context", + Effect.fn(function* (ctx) { + return yield* session.context(ctx.params.sessionID) + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 0b4bc252c3..e53eca3eff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -64,6 +64,7 @@ import { questionHandlers } from "./handlers/question" import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" +import { v2Handlers } from "./handlers/v2" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" @@ -115,6 +116,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( providerHandlers, sessionHandlers, syncHandlers, + v2Handlers, tuiHandlers, workspaceHandlers, ]), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index f0da2f3d85..3f9f3f6607 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -1,7 +1,8 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Effect } from "effect" +import { Context, Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" @@ -25,10 +26,135 @@ import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" +import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" +import { ExperimentalHttpApiServer } from "./httpapi/server" +import { EventPaths } from "./httpapi/event" +import { ExperimentalPaths } from "./httpapi/groups/experimental" +import { FilePaths } from "./httpapi/groups/file" +import { InstancePaths } from "./httpapi/groups/instance" +import { McpPaths } from "./httpapi/groups/mcp" +import { PtyPaths } from "./httpapi/groups/pty" +import { SessionPaths } from "./httpapi/groups/session" +import { SyncPaths } from "./httpapi/groups/sync" +import { TuiPaths } from "./httpapi/groups/tui" +import { WorkspacePaths } from "./httpapi/groups/workspace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { const app = new Hono() + const handler = ExperimentalHttpApiServer.webHandler().handler + const context = Context.empty() as Context.Context + + app.all("/api/*", (c) => handler(c.req.raw, context)) + + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + app.get(EventPaths.event, (c) => handler(c.req.raw, context)) + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) + app.patch("/config", (c) => handler(c.req.raw, context)) + app.get("/config/providers", (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) + app.get("/provider", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) + app.post("/project/git/init", (c) => handler(c.req.raw, context)) + app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) + app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) + app.get(FilePaths.list, (c) => handler(c.req.raw, context)) + app.get(FilePaths.content, (c) => handler(c.req.raw, context)) + app.get(FilePaths.status, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) + app.get(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) + app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) + app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) + app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) + app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) + app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + } return app .route("/project", ProjectRoutes()) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aaee2be2fe..067d43da2e 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,10 +14,13 @@ import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context, Schema } from "effect" +import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" const log = Log.create({ service: "session.compaction" }) @@ -556,7 +559,21 @@ export const layer: Layer.Layer< } if (processor.message.error) return "stop" - if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + if (result === "continue") { + const summary = summaryText( + (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { + info: msg, + parts: [], + }, + ) + EventV2.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + include: selected.tail_start_id, + }) + yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + } return result }) @@ -583,6 +600,11 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) + EventV2.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) }) return Service.of({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b475ec1c59..1a32a656d1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,9 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -221,6 +224,12 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -248,6 +257,13 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -260,6 +276,13 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -281,13 +304,34 @@ export const layer: Layer.Layer< case "tool-input-delta": return - case "tool-input-end": + case "tool-input-end": { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) return + } case "tool-call": { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -331,11 +375,48 @@ export const layer: Layer.Layer< } case "tool-result": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: value.output.metadata, + content: [ + { + type: "text", + text: value.output.output, + }, + ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Error.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* failToolCall(value.toolCallId, value.error) return } @@ -345,6 +426,20 @@ export const layer: Layer.Layer< case "start-step": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: ctx.model.id, + providerID: ctx.model.providerID, + variant: input.assistantMessage.variant, + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -355,18 +450,30 @@ export const layer: Layer.Layer< return case "finish-step": { + const completedSnapshot = yield* snapshot.track() const usage = Session.getUsage({ model: ctx.model, usage: value.usage, metadata: value.providerMetadata, }) + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ id: PartID.ascending(), reason: value.finishReason, - snapshot: yield* snapshot.track(), + snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "step-finish", @@ -404,6 +511,13 @@ export const layer: Layer.Layer< } case "text-start": + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -442,6 +556,14 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -568,13 +690,24 @@ export const layer: Layer.Layer< Effect.retry( SessionRetry.policy({ parse, - set: (info) => - status.set(ctx.sessionID, { + set: (info) => { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + return status.set(ctx.sessionID, { type: "retry", attempt: info.attempt, message: info.message, next: info.next, - }), + }) + }, }), ), Effect.catch(halt), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts new file mode 100644 index 0000000000..951e3e874f --- /dev/null +++ b/packages/opencode/src/session/projectors-next.ts @@ -0,0 +1,204 @@ +import { and, desc, eq } from "@/storage/db" +import type { Database } from "@/storage/db" +import { SessionMessage } from "@/v2/session-message" +import { SessionMessageUpdater } from "@/v2/session-message-updater" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" +import { SyncEvent } from "@/sync" +import { SessionMessageTable, SessionTable } from "./session.sql" +import type { SessionID } from "./schema" +import { Schema } from "effect" + +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) +type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]> + +function encodeDateTimes(value: unknown): unknown { + if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value) + if (Array.isArray(value)) return value.map(encodeDateTimes) + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)])) + } + return value +} + +function encodeMessageData(value: unknown): SessionMessageData { + return encodeDateTimes(value) as SessionMessageData +} + +function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter { + return { + getCurrentAssistant() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed) + }, + getCurrentCompaction() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Compaction => message.type === "compaction") + }, + getCurrentShell(callID) { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + }, + updateAssistant(assistant) { + const { id, type, ...data } = assistant + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateCompaction(compaction) { + const { id, type, ...data } = compaction + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateShell(shell) { + const { id, type, ...data } = shell + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + appendMessage(message) { + const { id, type, ...data } = message + db.insert(SessionMessageTable) + .values([ + { + id, + session_id: sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data: encodeMessageData(data), + }, + ]) + .run() + }, + finish() {}, + } +} + +function update(db: Database.TxOrDb, event: SessionEvent.Event) { + SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event) +} + +export default [ + SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + agent: data.agent, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data }) + }), + SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + model: { + id: data.id, + providerID: data.providerID, + variant: data.variant, + }, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data }) + }), + SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data }) + }), + SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data }) + }), + SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data }) + }), + SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data }) + }), + SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data }) + }), + SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) + }), + SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) + }), + SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data }) + }), + SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) + }), + SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data }) + }), + SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data }) + }), +] diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index a3832ebe65..9819ad810f 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" +import nextProjectors from "./projectors-next" const log = Log.create({ service: "session.projector" }) @@ -136,4 +137,6 @@ export default [ log.warn("ignored late part update", { partID: id, messageID, sessionID }) } }), + + ...nextProjectors, ] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9f1420388e..0590fc3827 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -54,6 +54,13 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import * as DateTime from "effect/DateTime" +import { eq } from "@/storage/db" +import * as Database from "@/storage/db" +import { SessionTable } from "./session.sql" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -785,6 +792,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } yield* sessions.updateMessage(msg) + const callID = ulid() + const started = Date.now() const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), @@ -794,11 +803,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the callID: ulid(), state: { status: "running", - time: { start: Date.now() }, + time: { start: started }, input: { command: input.command }, }, } yield* sessions.updatePart(part) + EventV2.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -813,14 +828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } + const completed = Date.now() + EventV2.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) if (!msg.time.completed) { - msg.time.completed = Date.now() + msg.time.completed = completed yield* sessions.updateMessage(msg) } if (part.state.status === "running") { part.state = { status: "completed", - time: { ...part.state.time, end: Date.now() }, + time: { ...part.state.time, end: completed }, input: part.state.input, title: "", metadata: { output, description: "" }, @@ -934,6 +956,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + if (current?.agent !== info.agent) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, + }) + } + if ( + current?.model?.providerID !== info.model.providerID || + current.model.id !== info.model.modelID || + current.model.variant !== info.model.variant + ) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + id: info.model.modelID, + providerID: info.model.providerID, + variant: info.model.variant, + }) + } + yield* Effect.addFinalizer(() => instruction.clear(info.id)) type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never @@ -1250,6 +1300,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(info) for (const part of parts) yield* sessions.updatePart(part) + const nextPrompt = parts.reduce( + (result, part) => { + if (part.type === "text") { + if (part.synthetic) result.synthetic.push(part.text) + else result.text.push(part.text) + } + if (part.type === "file") { + result.files.push( + new FileAttachment({ + uri: part.url, + mime: part.mime, + name: part.filename, + source: part.source + ? new Source({ + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + }) + : undefined, + }), + ) + } + if (part.type === "agent") { + result.agents.push( + new AgentAttachment({ + name: part.name, + source: part.source + ? new Source({ + start: part.source.start, + end: part.source.end, + text: part.source.value, + }) + : undefined, + }), + ) + } + return result + }, + { + text: [] as string[], + files: [] as FileAttachment[], + agents: [] as AgentAttachment[], + synthetic: [] as string[], + }, + ) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Prompted.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, + }) + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } return { info, parts } }, Effect.scoped) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 863fb21d65..421fa68694 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionEntry } from "../v2/session-entry" +import type { SessionMessage } from "../v2/session-message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" @@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit +type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( "session", @@ -34,6 +35,12 @@ export const SessionTable = sqliteTable( summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), + agent: text(), + model: text({ mode: "json" }).$type<{ + id: string + providerID: string + variant?: string + }>(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -96,22 +103,22 @@ export const TodoTable = sqliteTable( ], ) -export const SessionEntryTable = sqliteTable( - "session_entry", +export const SessionMessageTable = sqliteTable( + "session_message", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), ...Timestamps, - data: text({ mode: "json" }).notNull().$type>(), + data: text({ mode: "json" }).notNull().$type(), }, (table) => [ - index("session_entry_session_idx").on(table.session_id), - index("session_entry_session_type_idx").on(table.session_id, table.type), - index("session_entry_time_created_idx").on(table.time_created), + index("session_message_session_idx").on(table.session_id), + index("session_message_session_type_idx").on(table.session_id, table.type), + index("session_message_time_created_idx").on(table.time_created), ], ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e1d0c527aa..fedfa8996e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" +import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" @@ -78,6 +79,10 @@ export function fromRow(row: SessionRow): Info { path: row.path ?? undefined, parentID: row.parent_id ?? undefined, title: row.title, + agent: row.agent ?? undefined, + model: row.model + ? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant } + : undefined, version: row.version, summary, share, @@ -102,6 +107,8 @@ export function toRow(info: Info) { directory: info.directory, path: info.path, title: info.title, + agent: info.agent, + model: info.model, version: info.version, share_url: info.share?.url, summary_additions: info.summary?.additions, @@ -160,6 +167,12 @@ const Revert = Schema.Struct({ diff: optionalOmitUndefined(Schema.String), }) +const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), +}) + export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -171,6 +184,8 @@ export const Info = Schema.Struct({ summary: optionalOmitUndefined(Summary), share: optionalOmitUndefined(Share), title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(Model), version: Schema.String, time: Time, permission: optionalOmitUndefined(Permission.Ruleset), @@ -201,6 +216,8 @@ export const CreateInput = Schema.optional( Schema.Struct({ parentID: Schema.optional(SessionID), title: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Model), permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), @@ -272,6 +289,8 @@ const UpdatedInfo = Schema.Struct({ summary: Schema.optional(Schema.NullOr(Summary)), share: Schema.optional(UpdatedShare), title: Schema.optional(Schema.NullOr(Schema.String)), + agent: Schema.optional(Schema.NullOr(Schema.String)), + model: Schema.optional(Schema.NullOr(Model)), version: Schema.optional(Schema.NullOr(Schema.String)), time: Schema.optional(UpdatedTime), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), @@ -404,6 +423,8 @@ export interface Interface { readonly create: (input?: { parentID?: SessionID title?: string + agent?: string + model?: Schema.Schema.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect @@ -464,6 +485,8 @@ export const layer: Layer.Layer parentID?: SessionID workspaceID?: WorkspaceID directory: string @@ -481,6 +504,8 @@ export const layer: Layer.Layer permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -601,6 +628,8 @@ export const layer: Layer.Layer = EffectSchema.Schem export type SerializedEvent = Event & { type: string } -type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void +type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise type PublishContext = { instance?: InstanceContext @@ -255,7 +255,7 @@ export function define< export function project( def: Def, - func: (db: Database.TxOrDb, data: Event["data"]) => void, + func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, ): [Definition, ProjectorFunc] { return [def, func as ProjectorFunc] } @@ -277,7 +277,7 @@ function process( // idempotent: need to ignore any events already logged Database.transaction((tx) => { - projector(tx, event.data) + projector(tx, event.data, event) if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { tx.insert(EventSequenceTable) @@ -308,7 +308,7 @@ function process( } const result = convertEvent(def.type, event.data) - const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) + const publish = (data: unknown) => ProjectBus.publish(def, data as Properties, { id: event.id }) if (result instanceof Promise) { void result.then(publish) } else { diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76eb..1c88712d7d 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // on the inner Zod rather than a transform wrapper — so optional ASTs whose // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts new file mode 100644 index 0000000000..fde8d4326f --- /dev/null +++ b/packages/opencode/src/v2/event.ts @@ -0,0 +1,53 @@ +import { Identifier } from "@/id/id" +import { SyncEvent } from "@/sync" +import { withStatics } from "@/util/schema" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Schema from "effect/Schema" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((s) => ({ + create: () => s.make(Identifier.create("evt", "ascending")), + })), +) +export type ID = Schema.Schema.Type + +export function define(input: { + type: Type + schema: Fields + aggregate: string + version?: number +}) { + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + type: Schema.Literal(input.type), + data: Schema.Struct(input.schema), + }).annotate({ + identifier: input.type, + }) + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: input.aggregate, + schema: Payload.fields.data, + }) + + return Object.assign(Payload, { + Sync, + version: input.version, + aggregate: input.aggregate, + }) +} + +export function run( + def: Def, + data: SyncEvent.Event["data"], + options?: { publish?: boolean }, +) { + if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return + SyncEvent.run(def, data, options) +} + +export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/schema.ts b/packages/opencode/src/v2/schema.ts new file mode 100644 index 0000000000..44587b838a --- /dev/null +++ b/packages/opencode/src/v2/schema.ts @@ -0,0 +1,10 @@ +import { DateTime, Schema, SchemaGetter } from "effect" + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) + +export * as V2Schema from "./schema" diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts deleted file mode 100644 index 3fe4266c04..0000000000 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { produce, type WritableDraft } from "immer" -import { SessionEvent } from "./session-event" -import { SessionEntry } from "./session-entry" - -export type MemoryState = { - entries: SessionEntry.Entry[] - pending: SessionEntry.Entry[] -} - -export interface Adapter { - readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined - readonly updateAssistant: (assistant: SessionEntry.Assistant) => void - readonly appendEntry: (entry: SessionEntry.Entry) => void - readonly appendPending: (entry: SessionEntry.Entry) => void - readonly finish: () => Result -} - -export function memory(state: MemoryState): Adapter { - const activeAssistantIndex = () => - state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - return { - getCurrentAssistant() { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = state.entries[index] - return assistant?.type === "assistant" ? assistant : undefined - }, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = state.entries[index] - if (current?.type !== "assistant") return - state.entries[index] = assistant - }, - appendEntry(entry) { - state.entries.push(entry) - }, - appendPending(entry) { - state.pending.push(entry) - }, - finish() { - return state - }, - } -} - -export function stepWith(adapter: Adapter, event: SessionEvent.Event): Result { - const currentAssistant = adapter.getCurrentAssistant() - type DraftAssistant = WritableDraft - type DraftTool = WritableDraft - type DraftText = WritableDraft - type DraftReasoning = WritableDraft - - const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => - assistant?.content.findLast( - (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), - ) - - const latestText = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftText => item.type === "text") - - const latestReasoning = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") - - SessionEvent.Event.match(event, { - prompt: (event) => { - const entry = SessionEntry.User.fromEvent(event) - if (currentAssistant) { - adapter.appendPending(entry) - return - } - adapter.appendEntry(entry) - }, - synthetic: (event) => { - adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) - }, - "step.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - }), - ) - } - adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) - }, - "step.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - draft.cost = event.cost - draft.tokens = event.tokens - }), - ) - } - }, - "text.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "text", - text: "", - }) - }), - ) - } - }, - "text.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "text.ended": () => {}, - "tool.input.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "tool", - callID: event.callID, - name: event.name, - time: { - created: event.timestamp, - }, - state: { - status: "pending", - input: "", - }, - }) - }), - ) - } - }, - "tool.input.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) - if (match && match.state.status === "pending") match.state.input += event.delta - }), - ) - } - }, - "tool.input.ended": () => {}, - "tool.called": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match) { - match.time.ran = event.timestamp - match.state = { - status: "running", - input: event.input, - } - } - }), - ) - } - }, - "tool.success": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: match.state.input, - output: event.output ?? "", - title: event.title, - metadata: event.metadata ?? {}, - attachments: [...(event.attachments ?? [])], - } - } - }), - ) - } - }, - "tool.error": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "error", - error: event.error, - input: match.state.input, - metadata: event.metadata ?? {}, - } - } - }), - ) - } - }, - "reasoning.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "reasoning", - text: "", - }) - }), - ) - } - }, - "reasoning.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "reasoning.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text = event.text - }), - ) - } - }, - retried: (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)] - }), - ) - } - }, - compacted: (event) => { - adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) - }, - }) - - return adapter.finish() -} - -export function step(old: MemoryState, event: SessionEvent.Event): MemoryState { - return produce(old, (draft) => { - stepWith(memory(draft as MemoryState), event) - }) -} - -export * as SessionEntryStepper from "./session-entry-stepper" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts deleted file mode 100644 index 66576a688e..0000000000 --- a/packages/opencode/src/v2/session-entry.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Schema } from "effect" -import { NonNegativeInt } from "@/util/schema" -import { SessionEvent } from "./session-event" - -export const ID = SessionEvent.ID -export type ID = Schema.Schema.Type - -const Base = { - id: SessionEvent.ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -} - -export class User extends Schema.Class("Session.Entry.User")({ - ...Base, - text: SessionEvent.Prompt.fields.text, - files: SessionEvent.Prompt.fields.files, - agents: SessionEvent.Prompt.fields.agents, - type: Schema.Literal("user"), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Prompt) { - return new User({ - id: event.id, - type: "user", - metadata: event.metadata, - text: event.text, - files: event.files, - agents: event.agents, - time: { created: event.timestamp }, - }) - } -} - -export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ - ...SessionEvent.Synthetic.fields, - ...Base, - type: Schema.Literal("synthetic"), -}) { - static fromEvent(event: SessionEvent.Synthetic) { - return new Synthetic({ - ...event, - time: { created: event.timestamp }, - }) - } -} - -export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ - status: Schema.Literal("pending"), - input: Schema.String, -}) {} - -export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Unknown), - title: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Unknown), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown), - attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), -}) {} - -export class ToolStateError extends Schema.Class("Session.Entry.ToolState.Error")({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Unknown), - error: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( - Schema.toTaggedUnion("status"), -) -export type ToolState = Schema.Schema.Type - -export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ - type: Schema.Literal("tool"), - callID: Schema.String, - name: Schema.String, - state: ToolState, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - ran: Schema.DateTimeUtc.pipe(Schema.optional), - completed: Schema.DateTimeUtc.pipe(Schema.optional), - pruned: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) {} - -export class AssistantText extends Schema.Class("Session.Entry.Assistant.Text")({ - type: Schema.Literal("text"), - text: Schema.String, -}) {} - -export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ - type: Schema.Literal("reasoning"), - text: Schema.String, -}) {} - -export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: NonNegativeInt, - error: SessionEvent.RetryError, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Retried) { - return new AssistantRetry({ - attempt: event.attempt, - error: event.error, - time: { - created: event.timestamp, - }, - }) - } -} - -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( - Schema.toTaggedUnion("type"), -) -export type AssistantContent = Schema.Schema.Type - -export class Assistant extends Schema.Class("Session.Entry.Assistant")({ - ...Base, - type: Schema.Literal("assistant"), - content: AssistantContent.pipe(Schema.Array), - retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), - tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, - cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, - }), - }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - completed: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) { - static fromEvent(event: SessionEvent.Step.Started) { - return new Assistant({ - id: event.id, - type: "assistant", - time: { - created: event.timestamp, - }, - content: [], - retries: [], - }) - } -} - -export class Compaction extends Schema.Class("Session.Entry.Compaction")({ - ...SessionEvent.Compacted.fields, - type: Schema.Literal("compaction"), - ...Base, -}) { - static fromEvent(event: SessionEvent.Compacted) { - return new Compaction({ - ...event, - type: "compaction", - time: { created: event.timestamp }, - }) - } -} - -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) - -export type Entry = Schema.Schema.Type - -export type Type = Entry["type"] - -/* -export interface Interface { - readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry - readonly fromSession: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SessionEntry") {} - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const decodeEntry = Schema.decodeUnknownSync(Entry) - - const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) - - const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { - return Database.use((db) => - db - .select() - .from(SessionEntryTable) - .where(eq(SessionEntryTable.session_id, sessionID)) - .orderBy(SessionEntryTable.id) - .all() - .map((row) => decode(row)), - ) - }) - - return Service.of({ - decode, - fromSession, - }) - }), -) -*/ - -export * as SessionEntry from "./session-entry" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index aaf71c8dcc..3af5932f0d 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,128 +1,119 @@ -import { Identifier } from "@/id/id" -import { NonNegativeInt, withStatics } from "@/util/schema" -import * as DateTime from "effect/DateTime" +import { SessionID } from "@/session/schema" +import { NonNegativeInt } from "@/util/schema" +import { EventV2 } from "./event" +import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" +export { FileAttachment } +import { ToolOutput } from "./tool-output" +import { ModelID, ProviderID } from "@/provider/schema" +import { V2Schema } from "./schema" -export namespace SessionEvent { - export const ID = Schema.String.pipe( - Schema.brand("Session.Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), - ) - export type ID = Schema.Schema.Type - type Stamp = Schema.Schema.Type - type BaseInput = { - id?: ID - metadata?: Record - timestamp?: Stamp - } +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export type Source = Schema.Schema.Type - const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, - } +const Base = { + timestamp: V2Schema.DateTimeUtcFromMillis, + sessionID: SessionID, +} - export class Source extends Schema.Class("Session.Event.Source")({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, - }) {} - - export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), - }) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } - } - - export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), - }) {} - - export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ - message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - }) {} - - export class Prompt extends Schema.Class("Session.Event.Prompt")({ +export const AgentSwitched = EventV2.define({ + type: "session.next.agent.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("prompt"), - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), - }) { - static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) { - return new Prompt({ - id: input.id ?? ID.create(), - type: "prompt", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - files: input.files, - agents: input.agents, - }) - } - } + agent: Schema.String, + }, +}) +export type AgentSwitched = Schema.Schema.Type - export class Synthetic extends Schema.Class("Session.Event.Synthetic")({ +export const ModelSwitched = EventV2.define({ + type: "session.next.model.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("synthetic"), - text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Synthetic({ - id: input.id ?? ID.create(), - type: "synthetic", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } + id: ModelID, + providerID: ProviderID, + variant: Schema.String.pipe(Schema.optional), + }, +}) +export type ModelSwitched = Schema.Schema.Type - export namespace Step { - export class Started extends Schema.Class("Session.Event.Step.Started")({ +export const Prompted = EventV2.define({ + type: "session.next.prompted", + aggregate: "sessionID", + version: 1, + schema: { + ...Base, + prompt: Prompt, + }, +}) +export type Prompted = Schema.Schema.Type + +export const Synthetic = EventV2.define({ + type: "session.next.synthetic", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + }, +}) +export type Synthetic = Schema.Schema.Type + +export namespace Shell { + export const Started = EventV2.define({ + type: "session.next.shell.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.started"), + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.shell.ended", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = Schema.Schema.Type +} + +export namespace Step { + export const Started = EventV2.define({ + type: "session.next.step.started", + aggregate: "sessionID", + schema: { + ...Base, + agent: Schema.String, model: Schema.Struct({ id: Schema.String, providerID: Schema.String, variant: Schema.String.pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) { - return new Started({ - id: input.id ?? ID.create(), - type: "step.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - model: input.model, - }) - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Started = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Step.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.step.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.ended"), - reason: Schema.String, + finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ input: NonNegativeInt, @@ -133,177 +124,118 @@ export namespace SessionEvent { write: NonNegativeInt, }), }), - }) { - static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "step.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - reason: input.reason, - cost: input.cost, - tokens: input.tokens, - }) - } - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Text { - export class Started extends Schema.Class("Session.Event.Text.Started")({ +export namespace Text { + export const Started = EventV2.define({ + type: "session.next.text.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "text.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Text.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.text.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "text.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Text.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.text.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "text.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Reasoning { - export class Started extends Schema.Class("Session.Event.Reasoning.Started")({ +export namespace Reasoning { + export const Started = EventV2.define({ + type: "session.next.reasoning.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "reasoning.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + reasoningID: Schema.String, + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Reasoning.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.reasoning.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.delta"), + reasoningID: Schema.String, delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "reasoning.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Reasoning.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.reasoning.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.ended"), + reasoningID: Schema.String, text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "reasoning.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Tool { - export namespace Input { - export class Started extends Schema.Class("Session.Event.Tool.Input.Started")({ +export namespace Tool { + export namespace Input { + export const Started = EventV2.define({ + type: "session.next.tool.input.started", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, name: Schema.String, - type: Schema.Literal("tool.input.started"), - }) { - static create(input: BaseInput & { callID: string; name: string }) { - return new Started({ - id: input.id ?? ID.create(), - type: "tool.input.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - name: input.name, - }) - } - } + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Tool.Input.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.tool.input.delta", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { callID: string; delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "tool.input.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Tool.Input.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.tool.input.ended", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { callID: string; text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "tool.input.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type + } - export class Called extends Schema.Class("Session.Event.Tool.Called")({ + export const Called = EventV2.define({ + type: "session.next.tool.called", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.called"), callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -311,148 +243,155 @@ export namespace SessionEvent { executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - tool: string - input: Record - provider: Called["provider"] - }, - ) { - return new Called({ - id: input.id ?? ID.create(), - type: "tool.called", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - tool: input.tool, - input: input.input, - provider: input.provider, - }) - } - } + }, + }) + export type Called = Schema.Schema.Type - export class Success extends Schema.Class("Session.Event.Tool.Success")({ + export const Progress = EventV2.define({ + type: "session.next.tool.progress", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.success"), callID: Schema.String, - title: Schema.String, - output: Schema.String.pipe(Schema.optional), - attachments: Schema.Array(FileAttachment).pipe(Schema.optional), + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), + }, + }) + export type Progress = Schema.Schema.Type + + export const Success = EventV2.define({ + type: "session.next.tool.success", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - title: string - output?: string - attachments?: FileAttachment[] - provider: Success["provider"] - }, - ) { - return new Success({ - id: input.id ?? ID.create(), - type: "tool.success", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - title: input.title, - output: input.output, - attachments: input.attachments, - provider: input.provider, - }) - } - } + }, + }) + export type Success = Schema.Schema.Type - export class Error extends Schema.Class("Session.Event.Tool.Error")({ + export const Error = EventV2.define({ + type: "session.next.tool.error", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.error"), callID: Schema.String, - error: Schema.String, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) { - return new Error({ - id: input.id ?? ID.create(), - type: "tool.error", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - error: input.error, - provider: input.provider, - }) - } - } - } + }, + }) + export type Error = Schema.Schema.Type +} - export class Retried extends Schema.Class("Session.Event.Retried")({ +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: NonNegativeInt.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export type RetryError = Schema.Schema.Type + +export const Retried = EventV2.define({ + type: "session.next.retried", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("retried"), attempt: NonNegativeInt, error: RetryError, - }) { - static create(input: BaseInput & { attempt: number; error: RetryError }) { - return new Retried({ - id: input.id ?? ID.create(), - type: "retried", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - attempt: input.attempt, - error: input.error, - }) - } - } + }, +}) +export type Retried = Schema.Schema.Type - export class Compacted extends Schema.Class("Session.Event.Compated")({ - ...Base, - type: Schema.Literal("compacted"), - auto: Schema.Boolean, - overflow: Schema.Boolean.pipe(Schema.optional), - }) { - static create(input: BaseInput & { auto: boolean; overflow?: boolean }) { - return new Compacted({ - id: input.id ?? ID.create(), - type: "compacted", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - auto: input.auto, - overflow: input.overflow, - }) - } - } - - export const Event = Schema.Union( - [ - Prompt, - Synthetic, - Step.Started, - Step.Ended, - Text.Started, - Text.Delta, - Text.Ended, - Tool.Input.Started, - Tool.Input.Delta, - Tool.Input.Ended, - Tool.Called, - Tool.Success, - Tool.Error, - Reasoning.Started, - Reasoning.Delta, - Reasoning.Ended, - Retried, - Compacted, - ], - { - mode: "oneOf", +export namespace Compaction { + export const Started = EventV2.define({ + type: "session.next.compaction.started", + aggregate: "sessionID", + schema: { + ...Base, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, - ).pipe(Schema.toTaggedUnion("type")) - export type Event = Schema.Schema.Type - export type Type = Event["type"] + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.compaction.delta", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + }, + }) + + export const Ended = EventV2.define({ + type: "session.next.compaction.ended", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + include: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type } + +export const All = Schema.Union( + [ + AgentSwitched, + ModelSwitched, + Prompted, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Text.Started, + Text.Delta, + Text.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Error, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + ], + { + mode: "oneOf", + }, +).pipe(Schema.toTaggedUnion("type")) + +// user +// assistant +// assistant +// assistant +// user +// compaction marker +// -> text +// assistant + +export type Event = Schema.Schema.Type +export type Type = Event["type"] + +export * as SessionEvent from "./session-event" diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts new file mode 100644 index 0000000000..844f6fe2d1 --- /dev/null +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -0,0 +1,411 @@ +import { produce, type WritableDraft } from "immer" +import { SessionEvent } from "./session-event" +import { SessionMessage } from "./session-message" + +export type MemoryState = { + messages: SessionMessage.Message[] +} + +export interface Adapter { + readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined + readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined + readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined + readonly updateAssistant: (assistant: SessionMessage.Assistant) => void + readonly updateCompaction: (compaction: SessionMessage.Compaction) => void + readonly updateShell: (shell: SessionMessage.Shell) => void + readonly appendMessage: (message: SessionMessage.Message) => void + readonly finish: () => Result +} + +export function memory(state: MemoryState): Adapter { + const activeAssistantIndex = () => + state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction") + const activeShellIndex = (callID: string) => + state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + + return { + getCurrentAssistant() { + const index = activeAssistantIndex() + if (index < 0) return + const assistant = state.messages[index] + return assistant?.type === "assistant" ? assistant : undefined + }, + getCurrentCompaction() { + const index = activeCompactionIndex() + if (index < 0) return + const compaction = state.messages[index] + return compaction?.type === "compaction" ? compaction : undefined + }, + getCurrentShell(callID) { + const index = activeShellIndex(callID) + if (index < 0) return + const shell = state.messages[index] + return shell?.type === "shell" ? shell : undefined + }, + updateAssistant(assistant) { + const index = activeAssistantIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "assistant") return + state.messages[index] = assistant + }, + updateCompaction(compaction) { + const index = activeCompactionIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "compaction") return + state.messages[index] = compaction + }, + updateShell(shell) { + const index = activeShellIndex(shell.callID) + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "shell") return + state.messages[index] = shell + }, + appendMessage(message) { + state.messages.push(message) + }, + finish() { + return state + }, + } +} + +export function update(adapter: Adapter, event: SessionEvent.Event): Result { + const currentAssistant = adapter.getCurrentAssistant() + type DraftAssistant = WritableDraft + type DraftTool = WritableDraft + type DraftText = WritableDraft + type DraftReasoning = WritableDraft + + const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => + assistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) + + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") + + const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => + assistant?.content.findLast( + (item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID, + ) + + SessionEvent.All.match(event, { + "session.next.agent.switched": (event) => { + adapter.appendMessage( + new SessionMessage.AgentSwitched({ + id: event.id, + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.model.switched": (event) => { + adapter.appendMessage( + new SessionMessage.ModelSwitched({ + id: event.id, + type: "model-switched", + metadata: event.metadata, + model: { + id: event.data.id, + providerID: event.data.providerID, + variant: event.data.variant, + }, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.prompted": (event) => { + adapter.appendMessage( + new SessionMessage.User({ + id: event.id, + type: "user", + metadata: event.metadata, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.synthetic": (event) => { + adapter.appendMessage( + new SessionMessage.Synthetic({ + sessionID: event.data.sessionID, + text: event.data.text, + id: event.id, + type: "synthetic", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.started": (event) => { + adapter.appendMessage( + new SessionMessage.Shell({ + id: event.id, + type: "shell", + metadata: event.metadata, + callID: event.data.callID, + command: event.data.command, + output: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.ended": (event) => { + const currentShell = adapter.getCurrentShell(event.data.callID) + if (currentShell) { + adapter.updateShell( + produce(currentShell, (draft) => { + draft.output = event.data.output + draft.time.completed = event.data.timestamp + }), + ) + } + }, + "session.next.step.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + }), + ) + } + adapter.appendMessage( + new SessionMessage.Assistant({ + id: event.id, + type: "assistant", + agent: event.data.agent, + model: event.data.model, + time: { created: event.data.timestamp }, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + }), + ) + }, + "session.next.step.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = event.data.finish + draft.cost = event.data.cost + draft.tokens = event.data.tokens + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } + }), + ) + } + }, + "session.next.text.started": () => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "text", + text: "", + }) + }), + ) + } + }, + "session.next.text.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.text.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.tool.input.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { + created: event.data.timestamp, + }, + state: { + status: "pending", + input: "", + }, + }) + }), + ) + } + }, + "session.next.tool.input.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) + if (match && match.state.status === "pending") match.state.input += event.data.delta + }), + ) + } + }, + "session.next.tool.input.ended": () => {}, + "session.next.tool.called": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match) { + match.provider = event.data.provider + match.time.ran = event.data.timestamp + match.state = { + status: "running", + input: event.data.input, + structured: {}, + content: [], + } + } + }), + ) + } + }, + "session.next.tool.progress": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.state.structured = event.data.structured + match.state.content = [...event.data.content] + } + }), + ) + } + }, + "session.next.tool.success": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "completed", + input: match.state.input, + structured: event.data.structured, + content: [...event.data.content], + } + } + }), + ) + } + }, + "session.next.tool.error": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "error", + error: event.data.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + } + }), + ) + } + }, + "session.next.reasoning.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + }) + }), + ) + } + }, + "session.next.reasoning.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.reasoning.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.retried": () => {}, + "session.next.compaction.started": (event) => { + adapter.appendMessage( + new SessionMessage.Compaction({ + id: event.id, + type: "compaction", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.compaction.delta": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary += event.data.text + }), + ) + } + }, + "session.next.compaction.ended": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary = event.data.text + draft.include = event.data.include + }), + ) + } + }, + }) + + return adapter.finish() +} + +export * as SessionMessageUpdater from "./session-message-updater" diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts new file mode 100644 index 0000000000..8ec99bc200 --- /dev/null +++ b/packages/opencode/src/v2/session-message.ts @@ -0,0 +1,178 @@ +import { Schema } from "effect" +import { Prompt } from "./session-prompt" +import { SessionEvent } from "./session-event" +import { EventV2 } from "./event" +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./schema" + +export const ID = EventV2.ID +export type ID = Schema.Schema.Type + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +} + +export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: SessionEvent.AgentSwitched.fields.data.fields.agent, +}) {} + +export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ + ...Base, + type: Schema.Literal("model-switched"), + model: Schema.Struct({ + id: SessionEvent.ModelSwitched.fields.data.fields.id, + providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, + variant: SessionEvent.ModelSwitched.fields.data.fields.variant, + }), +}) {} + +export class User extends Schema.Class("Session.Message.User")({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +}) {} + +export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ + ...Base, + sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, + text: SessionEvent.Synthetic.fields.data.fields.text, + type: Schema.Literal("synthetic"), +}) {} + +export class Shell extends Schema.Class("Session.Message.Shell")({ + ...Base, + type: Schema.Literal("shell"), + callID: SessionEvent.Shell.Started.fields.data.fields.callID, + command: SessionEvent.Shell.Started.fields.data.fields.command, + output: Schema.String, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ + status: Schema.Literal("pending"), + input: Schema.String, +}) {} + +export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: ToolOutput.Structured, + content: ToolOutput.Content.pipe(Schema.Array), +}) {} + +export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, +}) {} + +export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), +}) {} + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = Schema.Schema.Type + +export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), + state: ToolState, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, +}) {} + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = Schema.Schema.Type + +export class Assistant extends Schema.Class("Session.Message.Assistant")({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: SessionEvent.Step.Started.fields.data.fields.model, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(Schema.optional), + end: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + finish: Schema.String.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }).pipe(Schema.optional), + error: Schema.String.pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class Compaction extends Schema.Class("Session.Message.Compaction")({ + type: Schema.Literal("compaction"), + reason: SessionEvent.Compaction.Started.fields.data.fields.reason, + summary: Schema.String, + include: Schema.String.pipe(Schema.optional), + ...Base, +}) {} + +export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) + +export type Message = Schema.Schema.Type + +export type Type = Message["type"] + +export * as SessionMessage from "./session-message" diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts new file mode 100644 index 0000000000..86d8e52eb7 --- /dev/null +++ b/packages/opencode/src/v2/session-prompt.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema" + +export class Source extends Schema.Class("Prompt.Source")({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}) {} + +export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) { + static create(input: FileAttachment) { + return new FileAttachment({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }) + } +} + +export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}) {} + +export class Prompt extends Schema.Class("Prompt")({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(Schema.optional), + agents: Schema.Array(AgentAttachment).pipe(Schema.optional), +}) {} diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 2bac11f4fe..1777b875aa 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -1,69 +1,279 @@ -import { Context, Layer, Schema, Effect } from "effect" -import { SessionEntry } from "./session-entry" -import { Struct } from "effect" -import { Session } from "@/session/session" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" +import { WorkspaceID } from "@/control-plane/schema" +import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" +import * as Database from "@/storage/db" +import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { SessionMessage } from "./session-message" +import type { Prompt } from "./session-prompt" +import { EventV2 } from "./event" +import { ProjectID } from "@/project/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { SessionEvent } from "./session-event" +import { V2Schema } from "./schema" -export const ID = SessionID +export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ + identifier: "Session.Delivery", +}) +export type Delivery = Schema.Schema.Type -export type ID = Schema.Schema.Type - -export class PromptInput extends Schema.Class("Session.PromptInput")({ - ...Struct.omit(SessionEntry.User.fields, ["time", "type"]), - id: Schema.optionalKey(SessionEntry.ID), - sessionID: ID, -}) {} - -export class CreateInput extends Schema.Class("Session.CreateInput")({ - id: Schema.optionalKey(ID), -}) {} +export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ - id: ID, + id: SessionID, + parentID: SessionID.pipe(Schema.optional), + projectID: ProjectID, + workspaceID: WorkspaceID.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + agent: Schema.String.pipe(Schema.optional), model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - modelID: Schema.String, + id: ModelID, + providerID: ProviderID, + variant: Schema.String.pipe(Schema.optional), }).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + updated: V2Schema.DateTimeUtcFromMillis, + archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), + title: Schema.String, + /* + slug: Schema.String, + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), + title: Schema.String, + version: Schema.String, + time: Time, + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), + */ }) {} export interface Interface { - fromID: (id: ID) => Effect.Effect - create: (input: CreateInput) => Effect.Effect - prompt: (input: PromptInput) => Effect.Effect + readonly list: (input: { + limit?: number + order?: "asc" | "desc" + directory?: string + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + cursor?: { + id: SessionID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly messages: (input: { + sessionID: SessionID + limit?: number + order?: "asc" | "desc" + cursor?: { + id: SessionMessage.ID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly context: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: { + id?: EventV2.ID + sessionID: SessionID + prompt: Prompt + delivery?: Delivery + }) => Effect.Effect + readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect + readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect + readonly switchModel: (input: { + sessionID: SessionID + id: ModelID + providerID: ProviderID + variant?: string + }) => Effect.Effect + readonly compact: (sessionID: SessionID) => Effect.Effect + readonly wait: (sessionID: SessionID) => Effect.Effect } -export class Service extends Context.Service()("Session.Service") {} +export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect(Service)( +export const layer = Layer.effect( + Service, Effect.gen(function* () { - const session = yield* Session.Service + const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) - const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) { - throw new Error("Not implemented") - }) + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }) - const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) { - throw new Error("Not implemented") - }) + function fromRow(row: typeof SessionTable.$inferSelect): Info { + return { + id: SessionID.make(row.id), + projectID: ProjectID.make(row.project_id), + workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, + title: row.title, + parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined, + path: row.path ?? "", + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, + time: { + created: DateTime.makeUnsafe(row.time_created), + updated: DateTime.makeUnsafe(row.time_updated), + archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + }, + } + } - const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) { - const match = yield* session.get(id) - return fromV1(match) - }) + const result: Interface = { + list: Effect.fn("V2Session.list")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const conditions: SQL[] = [] + if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.path) + conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if (input.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input.start) conditions.push(gte(SessionTable.time_created, input.start)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.cursor) { + conditions.push( + order === "asc" + ? or( + gt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), gt(SessionTable.id, input.cursor.id)), + )! + : or( + lt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), lt(SessionTable.id, input.cursor.id)), + )!, + ) + } + const query = Database.Client() + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(SessionTable.time_created) : desc(SessionTable.time_created), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) - return Service.of({ - create, - prompt, - fromID, - }) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const boundary = input.cursor + ? order === "asc" + ? or( + gt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + gt(SessionMessageTable.id, input.cursor.id), + ), + ) + : or( + lt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + lt(SessionMessageTable.id, input.cursor.id), + ), + ) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + + const rows = Database.use((db) => { + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy( + order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), + order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), + ) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return direction === "previous" ? rows.toReversed() : rows + }) + return rows.map((row) => decode(row)) + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + const rows = Database.use((db) => { + const compaction = db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) + .limit(1) + .get() + + return db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + compaction + ? or( + gt(SessionMessageTable.time_created, compaction.time_created), + and( + eq(SessionMessageTable.time_created, compaction.time_created), + gte(SessionMessageTable.id, compaction.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) + .all() + }) + return rows.map((row) => decode(row)) + }), + prompt: Effect.fn("V2Session.prompt")(function* (_input) { + return {} as any + }), + shell: Effect.fn("V2Session.shell")(function* (_input) {}), + skill: Effect.fn("V2Session.skill")(function* (_input) {}), + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + agent: input.agent, + }) + }), + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + id: input.id, + providerID: input.providerID, + variant: input.variant, + }) + }), + compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), + wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), + } + + return Service.of(result) }), ) -function fromV1(input: Session.Info): Info { - return new Info({ - id: ID.make(input.id), - }) -} +export const defaultLayer = layer export * as SessionV2 from "./session" diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/opencode/src/v2/tool-output.ts new file mode 100644 index 0000000000..dee2bb11ed --- /dev/null +++ b/packages/opencode/src/v2/tool-output.ts @@ -0,0 +1,18 @@ +export * as ToolOutput from "./tool-output" +import { Schema } from "effect" + +export class TextContent extends Schema.Class("Tool.TextContent")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class FileContent extends Schema.Class("Tool.FileContent")({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), +}) {} + +export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type")) + +export const Structured = Schema.Record(Schema.String, Schema.Any) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 9a92fc5072..2722757ab9 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -59,6 +59,7 @@ function toolEvent( raw: opts.raw, } const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, type: "message.part.updated", properties: { sessionID: sessionId, diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 5b0fcad3c9..78253361b7 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string }) function vcs(branch: string): Event { return { + id: `evt_vcs_${branch}`, type: "vcs.branch.updated", properties: { branch, @@ -34,6 +35,7 @@ function vcs(branch: string): Event { function update(version: string): Event { return { + id: `evt_update_${version}`, type: "installation.update-available", properties: { version, diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 479da7f518..b408f7ef11 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,7 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") +process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills @@ -79,7 +80,7 @@ delete process.env["OPENCODE_SERVER_USERNAME"] process.env["OPENCODE_DB"] = ":memory:" // Now safe to import from src/ -const Log = await import("@opencode-ai/core/util/log") +const { Log } = await import("@opencode-ai/core/util/log") const { initProjectors } = await import("../src/server/projectors") void Log.init({ diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 352fb2e2fa..b7ffa0ca5e 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -226,7 +226,14 @@ describe("HttpApi server", () => { const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) - expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ + "GET /api/session", + "GET /api/session/{sessionID}/context", + "GET /api/session/{sessionID}/message", + "POST /api/session/{sessionID}/compact", + "POST /api/session/{sessionID}/prompt", + "POST /api/session/{sessionID}/wait", + ]) }) test("matches generated OpenAPI route parameters", async () => { diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index d7e48240a9..940efed9c3 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -27,6 +27,14 @@ async function readFirstChunk(response: Response) { return new TextDecoder().decode(result.value) } +async function readFirstEvent(response: Response) { + return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as { + id?: string + type: string + properties: Record + } +} + afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() @@ -43,7 +51,7 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") expect(response.headers.get("x-accel-buffering")).toBe("no") expect(response.headers.get("x-content-type-options")).toBe("nosniff") - expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') + expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) test("matches legacy first event frame", async () => { @@ -52,6 +60,9 @@ describe("event HttpApi bridge", () => { const legacy = await app(false).request(EventPaths.event, { headers }) const effect = await app(true).request(EventPaths.event, { headers }) - expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy)) + const legacyEvent = await readFirstEvent(legacy) + const effectEvent = await readFirstEvent(effect) + expect(effectEvent.type).toBe(legacyEvent.type) + expect(effectEvent.properties).toEqual(legacyEvent.properties) }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 70fe2d81b3..d96347bed8 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -17,7 +17,9 @@ import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" -import { SessionTable } from "@/session/session.sql" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" +import { SessionMessage } from "../../src/v2/session-message" +import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" @@ -203,6 +205,45 @@ describe("session HttpApi", () => { { headers }, ), ).toMatchObject({ info: { id: message.info.id } }) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const message = new SessionMessage.Assistant({ + id: SessionMessage.ID.create(), + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: DateTime.makeUnsafe(1) }, + content: [], + }) + Database.use((db) => + db + .insert(SessionMessageTable) + .values([ + { + id: message.id, + session_id: parent.id, + type: message.type, + time_created: 1, + data: { + time: { created: 1 }, + agent: message.agent, + model: message.model, + content: message.content, + } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run(), + ) + }, + }), + ) + + expect( + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })).items, + ).toMatchObject([{ type: "assistant" }]) }), ), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index df83adb8d4..0d02d9918a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -20,6 +20,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { SessionV2 } from "../../src/v2/session" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -597,6 +598,15 @@ describe("session.compaction.create", () => { auto: true, overflow: true, }) + + const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( + Effect.provide(SessionV2.defaultLayer), + ) + expect(v2.at(-1)).toMatchObject({ + type: "compaction", + reason: "auto", + summary: "", + }) }), ), ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5330569401..a602c0c8d7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -19,6 +19,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" +import { SessionMessageTable } from "../../src/session/session.sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -31,6 +32,7 @@ import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionV2 } from "../../src/v2/session" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -39,6 +41,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" @@ -371,6 +374,47 @@ it.live("loop calls LLM and returns assistant message", () => ), ) +it.live("prompt emits v2 prompted and synthetic events", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "hello v2" }, + { + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,bm90ZSBjb250ZW50", + }, + ], + }) + + const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( + Effect.provide(SessionV2.layer), + ) + const row = Database.use((db) => + db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(), + ) + expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) + expect(typeof row?.data.time.created).toBe("number") + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), + expect.objectContaining({ type: "synthetic", text: "note content" }), + ]), + ) + }), + { git: true, config: providerCfg }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts deleted file mode 100644 index defce40c14..0000000000 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ /dev/null @@ -1,916 +0,0 @@ -import { describe, expect, test } from "bun:test" -import * as DateTime from "effect/DateTime" -import * as FastCheck from "effect/testing/FastCheck" -import { SessionEntry } from "../../src/v2/session-entry" -import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" -import { SessionEvent } from "../../src/v2/session-event" - -const time = (n: number) => DateTime.makeUnsafe(n) - -const word = FastCheck.string({ minLength: 1, maxLength: 8 }) -const text = FastCheck.string({ maxLength: 16 }) -const texts = FastCheck.array(text, { maxLength: 8 }) -const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 })) -const dict = FastCheck.dictionary(word, val, { maxKeys: 4 }) -const files = FastCheck.array( - word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })), - { maxLength: 2 }, -) - -function maybe(arb: FastCheck.Arbitrary) { - return FastCheck.oneof(FastCheck.constant(undefined), arb) -} - -function assistant() { - return new SessionEntry.Assistant({ - id: SessionEvent.ID.create(), - type: "assistant", - time: { created: time(0) }, - content: [], - retries: [], - }) -} - -function retryError(message: string) { - return new SessionEvent.RetryError({ - message, - isRetryable: true, - }) -} - -function retry(attempt: number, message: string, created: number) { - return new SessionEntry.AssistantRetry({ - attempt, - error: retryError(message), - time: { - created: time(created), - }, - }) -} - -function memoryState() { - const state: SessionEntryStepper.MemoryState = { - entries: [], - pending: [], - } - return state -} - -function active() { - const state: SessionEntryStepper.MemoryState = { - entries: [assistant()], - pending: [], - } - return state -} - -function run(events: SessionEvent.Event[], state = memoryState()) { - return events.reduce((state, event) => SessionEntryStepper.step(state, event), state) -} - -function last(state: SessionEntryStepper.MemoryState) { - const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant") - expect(entry?.type).toBe("assistant") - return entry?.type === "assistant" ? entry : undefined -} - -function texts_of(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") -} - -function reasons(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning") -} - -function tools(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool") -} - -function tool(state: SessionEntryStepper.MemoryState, callID: string) { - return tools(state).find((x) => x.callID === callID) -} - -function retriesOf(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.retries ?? [] -} - -function adapterStore() { - return { - committed: [] as SessionEntry.Entry[], - deferred: [] as SessionEntry.Entry[], - } -} - -function adapterFor(store: ReturnType): SessionEntryStepper.Adapter { - const activeAssistantIndex = () => - store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - const getCurrentAssistant = () => { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = store.committed[index] - return assistant?.type === "assistant" ? assistant : undefined - } - - return { - getCurrentAssistant, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = store.committed[index] - if (current?.type !== "assistant") return - store.committed[index] = assistant - }, - appendEntry(entry) { - store.committed.push(entry) - }, - appendPending(entry) { - store.deferred.push(entry) - }, - finish() { - return store - }, - } -} - -describe("session-entry-stepper", () => { - describe("stepWith", () => { - test("reduces through a custom adapter", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }), - ) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(7), - }), - ) - - expect(store.deferred).toHaveLength(1) - expect(store.deferred[0]?.type).toBe("user") - expect(store.committed).toHaveLength(1) - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].content).toEqual([ - { type: "reasoning", text: "thought" }, - { type: "text", text: "world" }, - ]) - expect(store.committed[0].time.completed).toEqual(time(7)) - }) - - test("aggregates retry events onto the current assistant", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 2, - error: retryError("provider overloaded"), - timestamp: time(2), - }), - ) - - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) - }) - }) - - describe("memory", () => { - test("tracks and replaces the current assistant", () => { - const state = active() - const adapter = SessionEntryStepper.memory(state) - const current = adapter.getCurrentAssistant() - - expect(current?.type).toBe("assistant") - if (!current) return - - adapter.updateAssistant( - new SessionEntry.Assistant({ - ...current, - content: [new SessionEntry.AssistantText({ type: "text", text: "done" })], - time: { - ...current.time, - completed: time(1), - }, - }), - ) - - expect(adapter.getCurrentAssistant()).toBeUndefined() - expect(state.entries[0]?.type).toBe("assistant") - if (state.entries[0]?.type !== "assistant") return - - expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }]) - expect(state.entries[0].time.completed).toEqual(time(1)) - }) - - test("appends committed and pending entries", () => { - const state = memoryState() - const adapter = SessionEntryStepper.memory(state) - const committed = SessionEntry.User.fromEvent( - SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }), - ) - const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) - - adapter.appendEntry(committed) - adapter.appendPending(pending) - - expect(state.entries).toEqual([committed]) - expect(state.pending).toEqual([pending]) - }) - - test("stepWith through memory records reasoning", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), - ) - - expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) - }) - - test("stepWith through memory records retries", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - - expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) - }) - }) - - describe("step", () => { - describe("seeded pending assistant", () => { - test("stores prompts in entries when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("stores prompts in pending when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - active(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("accumulates text deltas on the latest text part", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = parts.reduce( - (state, part, i) => - SessionEntryStepper.step( - state, - SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }), - ), - SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), - ) - - expect(texts_of(next)).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("routes later text deltas to the latest text segment", () => { - FastCheck.assert( - FastCheck.property(texts, texts, (a, b) => { - const next = run( - [ - SessionEvent.Text.Started.create({ timestamp: time(1) }), - ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), - ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), - ], - active(), - ) - - expect(texts_of(next)).toEqual([ - { type: "text", text: a.join("") }, - { type: "text", text: b.join("") }, - ]) - }), - { numRuns: 50 }, - ) - }) - - test("reasoning.ended replaces buffered reasoning text", () => { - FastCheck.assert( - FastCheck.property(texts, text, (parts, end) => { - const next = run( - [ - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: end, - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("tool.success completes the latest running tool", () => { - FastCheck.assert( - FastCheck.property( - word, - word, - dict, - maybe(text), - maybe(dict), - maybe(files), - texts, - (callID, title, input, output, metadata, attachments, parts) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - ...parts.map((x, i) => - SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), - ), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(parts.length + 2), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(parts.length + 3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return - - expect(match.time.ran).toEqual(time(parts.length + 2)) - expect(match.state.input).toEqual(input) - expect(match.state.output).toBe(output ?? "") - expect(match.state.title).toBe(title) - expect(match.state.metadata).toEqual(metadata ?? {}) - expect(match.state.attachments).toEqual(attachments ?? []) - }, - ), - { numRuns: 50 }, - ) - }) - - test("tool.error completes the latest running tool with an error", () => { - FastCheck.assert( - FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Error.create({ - callID, - error, - metadata, - provider: { executed: true }, - timestamp: time(3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe(error) - expect(match.state.metadata).toEqual(metadata ?? {}) - }), - { numRuns: 50 }, - ) - }) - - test("tool.success is ignored before tool.called promotes the tool to running", () => { - FastCheck.assert( - FastCheck.property(word, word, (callID, title) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Success.create({ - callID, - title, - provider: { executed: true }, - timestamp: time(2), - }), - ], - active(), - ) - const match = tool(next, callID) - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }), - { numRuns: 50 }, - ) - }) - - test("step.ended copies completion fields onto the pending assistant", () => { - FastCheck.assert( - FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { - const event = SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(n), - }) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }), - { numRuns: 50 }, - ) - }) - }) - - describe("known reducer gaps", () => { - test("prompt appends immutably when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = memoryState() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("prompt appends immutably when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = active() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("step.started creates an assistant consumed by follow-up events", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = run([ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - const entry = last(next) - - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(entry.time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 100 }, - ) - }) - - test("replays prompt -> step -> text -> step.ended", () => { - FastCheck.assert( - FastCheck.property(word, texts, (body, parts) => { - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return - - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 50 }, - ) - }) - - test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - FastCheck.assert( - FastCheck.property( - word, - texts, - text, - dict, - word, - maybe(text), - maybe(dict), - maybe(files), - (body, reason, end, input, title, output, metadata, attachments) => { - const callID = "call" - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), - ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(reason.length + 5), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(reason.length + 6), - }), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(reason.length + 7), - }), - ]) - - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return - - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: end, - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(reason.length + 7)) - }, - ), - { numRuns: 50 }, - ) - }) - - test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run( - [ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - ], - active(), - ) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("assistant") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return - - expect(next.entries[0].time.completed).toEqual(time(1)) - expect(next.entries[1].time.created).toEqual(time(1)) - expect(next.entries[1].time.completed).toBeUndefined() - }) - - test("handles sequential tools independently", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, (a, b, title, error) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title, - output: "done", - provider: { executed: true }, - timestamp: time(3), - }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "bash", - input: b, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Error.create({ - callID: "b", - error, - provider: { executed: true }, - timestamp: time(6), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(a) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe(title) - - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(b) - expect(second.state.error).toBe(error) - }), - { numRuns: 50 }, - ) - }) - - test("routes tool events by callID when tool streams interleave", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), - SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), - SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "grep", - input: b, - provider: { executed: true }, - timestamp: time(6), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title: titleA, - output: "done-a", - provider: { executed: true }, - timestamp: time(7), - }), - SessionEvent.Tool.Success.create({ - callID: "b", - title: titleB, - output: "done-b", - provider: { executed: true }, - timestamp: time(8), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return - - expect(first.state.input).toEqual(a) - expect(second.state.input).toEqual(b) - expect(first.state.title).toBe(titleA) - expect(second.state.title).toBe(titleB) - }), - { numRuns: 50 }, - ) - }) - - test("records synthetic events", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("records compaction events", () => { - FastCheck.assert( - FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(auto) - expect(next.entries[0].overflow).toBe(overflow) - }), - { numRuns: 50 }, - ) - }) - }) - }) -}) diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 0afbb18317..234c5246ee 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -124,7 +124,7 @@ describe("SyncEvent", () => { yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) yield* Effect.promise(() => received) expect(events).toHaveLength(1) - expect(events[0]).toEqual({ + expect(events[0]).toMatchObject({ type: "item.created", properties: { id: "evt_1", diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts new file mode 100644 index 0000000000..128177167c --- /dev/null +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "bun:test" +import * as DateTime from "effect/DateTime" +import { SessionID } from "../../src/session/schema" +import { EventV2 } from "../../src/v2/event" +import { SessionEvent } from "../../src/v2/session-event" +import { SessionMessageUpdater } from "../../src/v2/session-message-updater" + +test("step snapshots carry over to assistant messages", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + snapshot: "before", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + finish: "stop", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + snapshot: "after", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].finish).toBe("stop") +}) + +test("text ended populates assistant text content", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "hello assistant", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }]) +}) + +test("tool completion stores completed timestamp", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const callID = "call" + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.input.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + callID, + name: "bash", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.called", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + callID, + tool: "bash", + input: { command: "pwd" }, + provider: { executed: true, metadata: { source: "provider" } }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.success", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + callID, + structured: {}, + content: [{ type: "text", text: "/tmp" }], + provider: { executed: true, metadata: { status: "done" } }, + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content[0]?.type).toBe("tool") + if (state.messages[0].content[0]?.type !== "tool") return + expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4)) + expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } }) +}) + +test("compaction events reduce to compaction message", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const id = EventV2.ID.create() + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id, + type: "session.next.compaction.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "auto", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "hello ", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "summary", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + text: "final summary", + include: "recent context", + }, + } satisfies SessionEvent.Event) + + expect(state.messages).toHaveLength(1) + expect(state.messages[0]).toMatchObject({ + id, + type: "compaction", + reason: "auto", + summary: "final summary", + include: "recent context", + time: { created: DateTime.makeUnsafe(1) }, + }) +}) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index e920cc0fdb..c490a0be70 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,7 +9,7 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono" +const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") if (openapiSource === "httpapi") { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67261d7499..74c5844626 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -20,10 +20,10 @@ import type { ConfigUpdateErrors, ConfigUpdateResponses, EventSubscribeResponses, - EventTuiCommandExecute, - EventTuiPromptAppend, - EventTuiSessionSelect, - EventTuiToastShow, + EventTuiCommandExecute2, + EventTuiPromptAppend2, + EventTuiSessionSelect2, + EventTuiToastShow2, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, @@ -90,6 +90,7 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, + Prompt, ProviderAuthResponses, ProviderListResponses, ProviderOauthAuthorizeErrors, @@ -126,6 +127,7 @@ import type { SessionDeleteMessageErrors, SessionDeleteMessageResponses, SessionDeleteResponses, + SessionDelivery, SessionDiffResponses, SessionForkResponses, SessionGetErrors, @@ -187,6 +189,14 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + V2SessionCompactResponses, + V2SessionContextResponses, + V2SessionListErrors, + V2SessionListResponses, + V2SessionMessagesErrors, + V2SessionMessagesResponses, + V2SessionPromptResponses, + V2SessionWaitResponses, VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, @@ -244,111 +254,6 @@ class HeyApiRegistry { } } -export class Config extends HeyApiClient { - /** - * Get global configuration - * - * Retrieve the current global OpenCode configuration settings and preferences. - */ - public get(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/config", - ...options, - }) - } - - /** - * Update global configuration - * - * Update global OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) - return (options?.client ?? this.client).patch({ - url: "/global/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Global extends HeyApiClient { - /** - * Get health - * - * Get health information about the OpenCode server. - */ - public health(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/health", - ...options, - }) - } - - /** - * Get global events - * - * Subscribe to global events from the OpenCode system using server-sent events. - */ - public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ - url: "/global/event", - ...options, - }) - } - - /** - * Dispose instance - * - * Clean up and dispose all OpenCode instances, releasing all resources. - */ - public dispose(options?: Options) { - return (options?.client ?? this.client).post({ - url: "/global/dispose", - ...options, - }) - } - - /** - * Upgrade opencode - * - * Upgrade opencode to the specified version or latest if not specified. - */ - public upgrade( - parameters?: { - target?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) - return (options?.client ?? this.client).post({ - url: "/global/upgrade", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _config?: Config - get config(): Config { - return (this._config ??= new Config({ client: this.client })) - } -} - export class Auth extends HeyApiClient { /** * Remove auth credentials @@ -512,6 +417,419 @@ export class App extends HeyApiClient { } } +export class Config extends HeyApiClient { + /** + * Get global configuration + * + * Retrieve the current global OpenCode configuration settings and preferences. + */ + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/config", + ...options, + }) + } + + /** + * Update global configuration + * + * Update global OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) + return (options?.client ?? this.client).patch({ + url: "/global/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Global extends HeyApiClient { + /** + * Get health + * + * Get health information about the OpenCode server. + */ + public health(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/health", + ...options, + }) + } + + /** + * Get global events + * + * Subscribe to global events from the OpenCode system using server-sent events. + */ + public event(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/global/event", + ...options, + }) + } + + /** + * Dispose instance + * + * Clean up and dispose all OpenCode instances, releasing all resources. + */ + public dispose(options?: Options) { + return (options?.client ?? this.client).post({ + url: "/global/dispose", + ...options, + }) + } + + /** + * Upgrade opencode + * + * Upgrade opencode to the specified version or latest if not specified. + */ + public upgrade( + parameters?: { + target?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) + return (options?.client ?? this.client).post({ + url: "/global/upgrade", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _config?: Config + get config(): Config { + return (this._config ??= new Config({ client: this.client })) + } +} + +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/event", + ...options, + ...params, + }) + } +} + +export class Config2 extends HeyApiClient { + /** + * Get configuration + * + * Retrieve the current OpenCode configuration settings and preferences. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config", + ...options, + ...params, + }) + } + + /** + * Update configuration + * + * Update OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + directory?: string + workspace?: string + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "config", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List config providers + * + * Get a list of all configured AI providers and their default models. + */ + public providers( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config/providers", + ...options, + ...params, + }) + } +} + +export class Console extends HeyApiClient { + /** + * Get active Console provider metadata + * + * Get the active Console org name and the set of provider IDs managed by that Console org. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console", + ...options, + ...params, + }) + } + + /** + * List switchable Console orgs + * + * Get the available Console orgs across logged-in accounts, including the current active org. + */ + public listOrgs( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console/orgs", + ...options, + ...params, + }) + } + + /** + * Switch active Console org + * + * Persist a new active Console account/org selection for the current local OpenCode state. + */ + public switchOrg( + parameters?: { + directory?: string + workspace?: string + accountID?: string + orgID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "accountID" }, + { in: "body", key: "orgID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/console/switch", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Session extends HeyApiClient { + /** + * List sessions + * + * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + */ + public list( + parameters?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "cursor" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + { in: "query", key: "archived" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session", + ...options, + ...params, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + export class Adapter extends HeyApiClient { /** * List workspace adapters @@ -737,11 +1055,474 @@ export class Workspace extends HeyApiClient { } } -export class Console extends HeyApiClient { +export class Experimental extends HeyApiClient { + private _console?: Console + get console(): Console { + return (this._console ??= new Console({ client: this.client })) + } + + private _session?: Session + get session(): Session { + return (this._session ??= new Session({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } + + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } +} + +export class Tool extends HeyApiClient { /** - * Get active Console provider metadata + * List tools * - * Get the active Console org name and the set of provider IDs managed by that Console org. + * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + */ + public list( + parameters: { + directory?: string + workspace?: string + provider: string + model: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "provider" }, + { in: "query", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool", + ...options, + ...params, + }) + } + + /** + * List tool IDs + * + * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. + */ + public ids( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool/ids", + ...options, + ...params, + }) + } +} + +export class Worktree extends HeyApiClient { + /** + * Remove worktree + * + * Remove a git worktree and delete its branch. + */ + public remove( + parameters?: { + directory?: string + workspace?: string + worktreeRemoveInput?: WorktreeRemoveInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeRemoveInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List worktrees + * + * List all sandbox worktrees for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/worktree", + ...options, + ...params, + }) + } + + /** + * Create worktree + * + * Create a new git worktree for the current project and run any configured startup scripts. + */ + public create( + parameters?: { + directory?: string + workspace?: string + worktreeCreateInput?: WorktreeCreateInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeCreateInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reset worktree + * + * Reset a worktree branch to the primary default branch. + */ + public reset( + parameters?: { + directory?: string + workspace?: string + worktreeResetInput?: WorktreeResetInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeResetInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree/reset", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Find extends HeyApiClient { + /** + * Find text + * + * Search for text patterns across files in the project using ripgrep. + */ + public text( + parameters: { + directory?: string + workspace?: string + pattern: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "pattern" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find", + ...options, + ...params, + }) + } + + /** + * Find files + * + * Search for files or directories by name or pattern in the project directory. + */ + public files( + parameters: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + { in: "query", key: "dirs" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/file", + ...options, + ...params, + }) + } + + /** + * Find symbols + * + * Search for workspace symbols like functions, classes, and variables using LSP. + */ + public symbols( + parameters: { + directory?: string + workspace?: string + query: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/symbol", + ...options, + ...params, + }) + } +} + +export class File extends HeyApiClient { + /** + * List files + * + * List files and directories in a specified path. + */ + public list( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file", + ...options, + ...params, + }) + } + + /** + * Read file + * + * Read the content of a specified file. + */ + public read( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/content", + ...options, + ...params, + }) + } + + /** + * Get file status + * + * Get the git status of all files in the project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/status", + ...options, + ...params, + }) + } +} + +export class Instance extends HeyApiClient { + /** + * Dispose instance + * + * Clean up and dispose the current OpenCode instance, releasing all resources. + */ + public dispose( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/instance/dispose", + ...options, + ...params, + }) + } +} + +export class Path extends HeyApiClient { + /** + * Get paths + * + * Retrieve the current working directory and related path information for the OpenCode instance. */ public get( parameters?: { @@ -761,19 +1542,21 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console", + return (options?.client ?? this.client).get({ + url: "/path", ...options, ...params, }) } +} +export class Vcs extends HeyApiClient { /** - * List switchable Console orgs + * Get VCS info * - * Get the available Console orgs across logged-in accounts, including the current active org. + * Retrieve version control system (VCS) information for the current project, such as git branch. */ - public listOrgs( + public get( parameters?: { directory?: string workspace?: string @@ -791,24 +1574,23 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console/orgs", + return (options?.client ?? this.client).get({ + url: "/vcs", ...options, ...params, }) } /** - * Switch active Console org + * Get VCS diff * - * Persist a new active Console account/org selection for the current local OpenCode state. + * Retrieve the current git diff for the working tree or against the default branch. */ - public switchOrg( - parameters?: { + public diff( + parameters: { directory?: string workspace?: string - accountID?: string - orgID?: string + mode: "git" | "branch" }, options?: Options, ) { @@ -819,14 +1601,209 @@ export class Console extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "accountID" }, - { in: "body", key: "orgID" }, + { in: "query", key: "mode" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/console/switch", + return (options?.client ?? this.client).get({ + url: "/vcs/diff", + ...options, + ...params, + }) + } +} + +export class Command extends HeyApiClient { + /** + * List commands + * + * Get a list of all available commands in the OpenCode system. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/command", + ...options, + ...params, + }) + } +} + +export class Lsp extends HeyApiClient { + /** + * Get LSP status + * + * Get LSP server status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/lsp", + ...options, + ...params, + }) + } +} + +export class Formatter extends HeyApiClient { + /** + * Get formatter status + * + * Get formatter status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/formatter", + ...options, + ...params, + }) + } +} + +export class Auth2 extends HeyApiClient { + /** + * Remove MCP OAuth + * + * Remove OAuth credentials for an MCP server. + */ + public remove( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Start MCP OAuth + * + * Start OAuth authentication flow for a Model Context Protocol (MCP) server. + */ + public start( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Complete MCP OAuth + * + * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. + */ + public callback( + parameters: { + name: string + directory?: string + workspace?: string + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth/callback", ...options, ...params, headers: { @@ -836,24 +1813,17 @@ export class Console extends HeyApiClient { }, }) } -} -export class Session extends HeyApiClient { /** - * List sessions + * Authenticate MCP OAuth * - * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + * Start OAuth flow and wait for callback (opens browser). */ - public list( - parameters?: { + public authenticate( + parameters: { + name: string directory?: string workspace?: string - roots?: boolean | "true" | "false" - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean | "true" | "false" }, options?: Options, ) { @@ -862,33 +1832,30 @@ export class Session extends HeyApiClient { [ { args: [ + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", - ...options, - ...params, - }) + return (options?.client ?? this.client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + ...params, + }, + ) } } -export class Resource extends HeyApiClient { +export class Mcp extends HeyApiClient { /** - * Get MCP resources + * Get MCP status * - * Get all available MCP resources from connected servers. Optionally filter by name. + * Get the status of all Model Context Protocol (MCP) servers. */ - public list( + public status( parameters?: { directory?: string workspace?: string @@ -906,33 +1873,115 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", + return (options?.client ?? this.client).get({ + url: "/mcp", ...options, ...params, }) } -} -export class Experimental extends HeyApiClient { - private _workspace?: Workspace - get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) + /** + * Add MCP server + * + * Dynamically add a new Model Context Protocol (MCP) server to the system. + */ + public add( + parameters?: { + directory?: string + workspace?: string + name?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) } - private _console?: Console - get console(): Console { - return (this._console ??= new Console({ client: this.client })) + /** + * Connect an MCP server. + */ + public connect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/connect", + ...options, + ...params, + }) } - private _session?: Session - get session(): Session { - return (this._session ??= new Session({ client: this.client })) + /** + * Disconnect an MCP server. + */ + public disconnect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/disconnect", + ...options, + ...params, + }) } - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) + private _auth?: Auth2 + get auth(): Auth2 { + return (this._auth ??= new Auth2({ client: this.client })) } } @@ -1329,147 +2378,48 @@ export class Pty extends HeyApiClient { } } -export class Config2 extends HeyApiClient { +export class Question extends HeyApiClient { /** - * Get configuration + * List pending questions * - * Retrieve the current OpenCode configuration settings and preferences. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config", - ...options, - ...params, - }) - } - - /** - * Update configuration - * - * Update OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - directory?: string - workspace?: string - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "config", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).patch({ - url: "/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List config providers - * - * Get a list of all configured AI providers and their default models. - */ - public providers( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config/providers", - ...options, - ...params, - }) - } -} - -export class Tool extends HeyApiClient { - /** - * List tool IDs - * - * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. - */ - public ids( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool/ids", - ...options, - ...params, - }) - } - - /** - * List tools - * - * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + * Get all pending question requests across all sessions. */ public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/question", + ...options, + ...params, + }) + } + + /** + * Reply to question request + * + * Provide answers to a question request from the AI assistant. + */ + public reply( parameters: { + requestID: string directory?: string workspace?: string - provider: string - model: string + answers?: Array }, options?: Options, ) { @@ -1478,50 +2428,16 @@ export class Tool extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "provider" }, - { in: "query", key: "model" }, + { in: "body", key: "answers" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool", - ...options, - ...params, - }) - } -} - -export class Worktree extends HeyApiClient { - /** - * Remove worktree - * - * Remove a git worktree and delete its branch. - */ - public remove( - parameters?: { - directory?: string - workspace?: string - worktreeRemoveInput?: WorktreeRemoveInput - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeRemoveInput", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reply", ...options, ...params, headers: { @@ -1533,9 +2449,43 @@ export class Worktree extends HeyApiClient { } /** - * List worktrees + * Reject question request * - * List all sandbox worktrees for the current project. + * Reject a question request from the AI assistant. + */ + public reject( + parameters: { + requestID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reject", + ...options, + ...params, + }) + } +} + +export class Permission extends HeyApiClient { + /** + * List pending permissions + * + * Get all pending permission requests across all sessions. */ public list( parameters?: { @@ -1555,23 +2505,25 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/worktree", + return (options?.client ?? this.client).get({ + url: "/permission", ...options, ...params, }) } /** - * Create worktree + * Respond to permission request * - * Create a new git worktree for the current project and run any configured startup scripts. + * Approve or deny a permission request from the AI assistant. */ - public create( - parameters?: { + public reply( + parameters: { + requestID: string directory?: string workspace?: string - worktreeCreateInput?: WorktreeCreateInput + reply?: "once" | "always" | "reject" + message?: string }, options?: Options, ) { @@ -1580,15 +2532,17 @@ export class Worktree extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", ...options, ...params, headers: { @@ -1600,15 +2554,153 @@ export class Worktree extends HeyApiClient { } /** - * Reset worktree + * Respond to permission * - * Reset a worktree branch to the primary default branch. + * Approve or deny a permission request from the AI assistant. + * + * @deprecated */ - public reset( + public respond( + parameters: { + sessionID: string + permissionID: string + directory?: string + workspace?: string + response?: "once" | "always" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "permissionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "response" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permissions/{permissionID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Oauth extends HeyApiClient { + /** + * Start OAuth authorization + * + * Start the OAuth authorization flow for a provider. + */ + public authorize( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + inputs?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "inputs" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/authorize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Handle OAuth callback + * + * Handle the OAuth callback from a provider after user authorization. + */ + public callback( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Provider extends HeyApiClient { + /** + * List providers + * + * Get a list of all available AI providers, including both available and connected ones. + */ + public list( parameters?: { directory?: string workspace?: string - worktreeResetInput?: WorktreeResetInput }, options?: Options, ) { @@ -1619,22 +2711,51 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", + return (options?.client ?? this.client).get({ + url: "/provider", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } + + /** + * Get provider auth methods + * + * Retrieve available authentication methods for all AI providers. + */ + public auth( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/provider/auth", + ...options, + ...params, + }) + } + + private _oauth?: Oauth + get oauth(): Oauth { + return (this._oauth ??= new Oauth({ client: this.client })) + } } export class Session2 extends HeyApiClient { @@ -1691,6 +2812,12 @@ export class Session2 extends HeyApiClient { workspace?: string parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string }, @@ -1705,6 +2832,8 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "parentID" }, { in: "body", key: "title" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, { in: "body", key: "permission" }, { in: "body", key: "workspaceID" }, ], @@ -1926,184 +3055,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Initialize session - * - * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. - */ - public init( - parameters: { - sessionID: string - directory?: string - workspace?: string - modelID?: string - providerID?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "modelID" }, - { in: "body", key: "providerID" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/init", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Fork session - * - * Create a new session by forking an existing session at a specific message point. - */ - public fork( - parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/fork", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Abort session - * - * Abort an active session and stop any ongoing AI processing or command execution. - */ - public abort( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/abort", - ...options, - ...params, - }) - } - - /** - * Unshare session - * - * Remove the shareable link for a session, making it private again. - */ - public unshare( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - - /** - * Share session - * - * Create a shareable link for a session, allowing others to view the conversation. - */ - public share( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - /** * Get message diff * @@ -2138,49 +3089,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Summarize session - * - * Generate a concise summary of the session using AI compaction to preserve key information. - */ - public summarize( - parameters: { - sessionID: string - directory?: string - workspace?: string - providerID?: string - modelID?: string - auto?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "providerID" }, - { in: "body", key: "modelID" }, - { in: "body", key: "auto" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/summarize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - /** * Get session messages * @@ -2280,7 +3188,7 @@ export class Session2 extends HeyApiClient { /** * Delete message * - * Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message. + * Permanently delete a specific message and all of its parts from a session without reverting file changes. */ public deleteMessage( parameters: { @@ -2349,6 +3257,227 @@ export class Session2 extends HeyApiClient { }) } + /** + * Fork session + * + * Create a new session by forking an existing session at a specific message point. + */ + public fork( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/fork", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Abort session + * + * Abort an active session and stop any ongoing AI processing or command execution. + */ + public abort( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/abort", + ...options, + ...params, + }) + } + + /** + * Initialize session + * + * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. + */ + public init( + parameters: { + sessionID: string + directory?: string + workspace?: string + modelID?: string + providerID?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "modelID" }, + { in: "body", key: "providerID" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/init", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Unshare session + * + * Remove the shareable link for a session, making it private again. + */ + public unshare( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Share session + * + * Create a shareable link for a session, allowing others to view the conversation. + */ + public share( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Summarize session + * + * Generate a concise summary of the session using AI compaction to preserve key information. + */ + public summarize( + parameters: { + sessionID: string + directory?: string + workspace?: string + providerID?: string + modelID?: string + auto?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + { in: "body", key: "auto" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/summarize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send async message * @@ -2591,7 +3720,7 @@ export class Session2 extends HeyApiClient { export class Part extends HeyApiClient { /** - * Delete a part from a message + * Delete a part from a message. */ public delete( parameters: { @@ -2625,7 +3754,7 @@ export class Part extends HeyApiClient { } /** - * Update a part in a message + * Update a part in a message. */ public update( parameters: { @@ -2666,386 +3795,6 @@ export class Part extends HeyApiClient { } } -export class Permission extends HeyApiClient { - /** - * Respond to permission - * - * Approve or deny a permission request from the AI assistant. - * - * @deprecated - */ - public respond( - parameters: { - sessionID: string - permissionID: string - directory?: string - workspace?: string - response?: "once" | "always" | "reject" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "permissionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "response" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/permissions/{permissionID}", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Respond to permission request - * - * Approve or deny a permission request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - reply?: "once" | "always" | "reject" - message?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/permission/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List pending permissions - * - * Get all pending permission requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/permission", - ...options, - ...params, - }) - } -} - -export class Question extends HeyApiClient { - /** - * List pending questions - * - * Get all pending question requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/question", - ...options, - ...params, - }) - } - - /** - * Reply to question request - * - * Provide answers to a question request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - answers?: Array - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Reject question request - * - * Reject a question request from the AI assistant. - */ - public reject( - parameters: { - requestID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", - ...options, - ...params, - }) - } -} - -export class Oauth extends HeyApiClient { - /** - * OAuth authorize - * - * Initiate OAuth authorization for a specific AI provider to get an authorization URL. - */ - public authorize( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - inputs?: { - [key: string]: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "inputs" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthAuthorizeResponses, - ProviderOauthAuthorizeErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/authorize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * OAuth callback - * - * Handle the OAuth callback from a provider after user authorization. - */ - public callback( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthCallbackResponses, - ProviderOauthCallbackErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/callback", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Provider extends HeyApiClient { - /** - * List providers - * - * Get a list of all available AI providers, including both available and connected ones. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider", - ...options, - ...params, - }) - } - - /** - * Get provider auth methods - * - * Retrieve available authentication methods for all AI providers. - */ - public auth( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider/auth", - ...options, - ...params, - }) - } - - private _oauth?: Oauth - get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) - } -} - export class History extends HeyApiClient { /** * List sync events @@ -3179,181 +3928,13 @@ export class Sync extends HeyApiClient { } } -export class Find extends HeyApiClient { +export class Session3 extends HeyApiClient { /** - * Find text + * List v2 sessions * - * Search for text patterns across files in the project using ripgrep. - */ - public text( - parameters: { - directory?: string - workspace?: string - pattern: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "pattern" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find", - ...options, - ...params, - }) - } - - /** - * Find files - * - * Search for files or directories by name or pattern in the project directory. - */ - public files( - parameters: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - { in: "query", key: "dirs" }, - { in: "query", key: "type" }, - { in: "query", key: "limit" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/file", - ...options, - ...params, - }) - } - - /** - * Find symbols - * - * Search for workspace symbols like functions, classes, and variables using LSP. - */ - public symbols( - parameters: { - directory?: string - workspace?: string - query: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/symbol", - ...options, - ...params, - }) - } -} - -export class File extends HeyApiClient { - /** - * List files - * - * List files and directories in a specified path. + * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. */ public list( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file", - ...options, - ...params, - }) - } - - /** - * Read file - * - * Read the content of a specified file. - */ - public read( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file/content", - ...options, - ...params, - }) - } - - /** - * Get file status - * - * Get the git status of all files in the project. - */ - public status( parameters?: { directory?: string workspace?: string @@ -3371,57 +3952,25 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/file/status", + return (options?.client ?? this.client).get({ + url: "/api/session", ...options, ...params, }) } -} -export class Event extends HeyApiClient { /** - * Subscribe to events + * Send v2 message * - * Get events + * Create a v2 session message and queue it for the agent loop. */ - public subscribe( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) - } -} - -export class Auth2 extends HeyApiClient { - /** - * Remove MCP OAuth - * - * Remove OAuth credentials for an MCP server - */ - public remove( + public prompt( parameters: { - name: string + sessionID: string directory?: string workspace?: string + prompt?: Prompt + delivery?: SessionDelivery }, options?: Options, ) { @@ -3430,81 +3979,17 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Start MCP OAuth - * - * Start OAuth authentication flow for a Model Context Protocol (MCP) server. - */ - public start( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Complete MCP OAuth - * - * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. - */ - public callback( - parameters: { - name: string - directory?: string - workspace?: string - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/prompt", ...options, ...params, headers: { @@ -3516,13 +4001,13 @@ export class Auth2 extends HeyApiClient { } /** - * Authenticate MCP OAuth + * Compact v2 session * - * Start OAuth flow and wait for callback (opens browser) + * Compact a v2 session conversation. */ - public authenticate( + public compact( parameters: { - name: string + sessionID: string directory?: string workspace?: string }, @@ -3533,156 +4018,121 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/compact", + ...options, + ...params, + }) + } + + /** + * Wait for v2 session + * + * Wait for a v2 session agent loop to become idle. + */ + public wait( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/wait", + ...options, + ...params, + }) + } + + /** + * Get v2 session context + * + * Retrieve the active context messages for a v2 session (all messages after the last compaction). + */ + public context( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/context", + ...options, + ...params, + }) + } + + /** + * Get v2 session messages + * + * Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public messages( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) } } -export class Mcp extends HeyApiClient { - /** - * Get MCP status - * - * Get the status of all Model Context Protocol (MCP) servers. - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/mcp", - ...options, - ...params, - }) - } - - /** - * Add MCP server - * - * Dynamically add a new Model Context Protocol (MCP) server to the system. - */ - public add( - parameters?: { - directory?: string - workspace?: string - name?: string - config?: McpLocalConfig | McpRemoteConfig - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "config" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Connect an MCP server - */ - public connect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/connect", - ...options, - ...params, - }) - } - - /** - * Disconnect an MCP server - */ - public disconnect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/disconnect", - ...options, - ...params, - }) - } - - private _auth?: Auth2 - get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) +export class V2 extends HeyApiClient { + private _session?: Session3 + get session(): Session3 { + return (this._session ??= new Session3({ client: this.client })) } } @@ -3690,7 +4140,7 @@ export class Control extends HeyApiClient { /** * Get next TUI request * - * Retrieve the next TUI (Terminal User Interface) request from the queue for processing. + * Retrieve the next TUI request from the queue for processing. */ public next( parameters?: { @@ -3759,7 +4209,7 @@ export class Tui extends HeyApiClient { /** * Append TUI prompt * - * Append prompt to the TUI + * Append prompt to the TUI. */ public appendPrompt( parameters?: { @@ -3826,7 +4276,7 @@ export class Tui extends HeyApiClient { /** * Open sessions dialog * - * Open the session dialog + * Open the session dialog. */ public openSessions( parameters?: { @@ -3856,7 +4306,7 @@ export class Tui extends HeyApiClient { /** * Open themes dialog * - * Open the theme dialog + * Open the theme dialog. */ public openThemes( parameters?: { @@ -3886,7 +4336,7 @@ export class Tui extends HeyApiClient { /** * Open models dialog * - * Open the model dialog + * Open the model dialog. */ public openModels( parameters?: { @@ -3916,7 +4366,7 @@ export class Tui extends HeyApiClient { /** * Submit TUI prompt * - * Submit the prompt + * Submit the prompt. */ public submitPrompt( parameters?: { @@ -3946,7 +4396,7 @@ export class Tui extends HeyApiClient { /** * Clear TUI prompt * - * Clear the prompt + * Clear the prompt. */ public clearPrompt( parameters?: { @@ -3976,7 +4426,7 @@ export class Tui extends HeyApiClient { /** * Execute TUI command * - * Execute a TUI command (e.g. agent_cycle) + * Execute a TUI command. */ public executeCommand( parameters?: { @@ -4013,7 +4463,7 @@ export class Tui extends HeyApiClient { /** * Show TUI toast * - * Show a toast notification in the TUI + * Show a toast notification in the TUI. */ public showToast( parameters?: { @@ -4056,13 +4506,13 @@ export class Tui extends HeyApiClient { /** * Publish TUI event * - * Publish a TUI event + * Publish a TUI event. */ public publish( parameters?: { directory?: string workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 }, options?: Options, ) { @@ -4133,230 +4583,6 @@ export class Tui extends HeyApiClient { } } -export class Instance extends HeyApiClient { - /** - * Dispose instance - * - * Clean up and dispose the current OpenCode instance, releasing all resources. - */ - public dispose( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/instance/dispose", - ...options, - ...params, - }) - } -} - -export class Path extends HeyApiClient { - /** - * Get paths - * - * Retrieve the current working directory and related path information for the OpenCode instance. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/path", - ...options, - ...params, - }) - } -} - -export class Vcs extends HeyApiClient { - /** - * Get VCS info - * - * Retrieve version control system (VCS) information for the current project, such as git branch. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs", - ...options, - ...params, - }) - } - - /** - * Get VCS diff - * - * Retrieve the current git diff for the working tree or against the default branch. - */ - public diff( - parameters: { - directory?: string - workspace?: string - mode: "git" | "branch" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs/diff", - ...options, - ...params, - }) - } -} - -export class Command extends HeyApiClient { - /** - * List commands - * - * Get a list of all available commands in the OpenCode system. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/command", - ...options, - ...params, - }) - } -} - -export class Lsp extends HeyApiClient { - /** - * Get LSP status - * - * Get LSP server status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/lsp", - ...options, - ...params, - }) - } -} - -export class Formatter extends HeyApiClient { - /** - * Get formatter status - * - * Get formatter status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/formatter", - ...options, - ...params, - }) - } -} - export class OpencodeClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -4365,11 +4591,6 @@ export class OpencodeClient extends HeyApiClient { OpencodeClient.__registry.set(this, args?.key) } - private _global?: Global - get global(): Global { - return (this._global ??= new Global({ client: this.client })) - } - private _auth?: Auth get auth(): Auth { return (this._auth ??= new Auth({ client: this.client })) @@ -4380,19 +4601,14 @@ export class OpencodeClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) + private _global?: Global + get global(): Global { + return (this._global ??= new Global({ client: this.client })) } - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) - } - - private _pty?: Pty - get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) } private _config?: Config2 @@ -4400,6 +4616,11 @@ export class OpencodeClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) @@ -4410,36 +4631,6 @@ export class OpencodeClient extends HeyApiClient { return (this._worktree ??= new Worktree({ client: this.client })) } - private _session?: Session2 - get session(): Session2 { - return (this._session ??= new Session2({ client: this.client })) - } - - private _part?: Part - get part(): Part { - return (this._part ??= new Part({ client: this.client })) - } - - private _permission?: Permission - get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) - } - - private _question?: Question - get question(): Question { - return (this._question ??= new Question({ client: this.client })) - } - - private _provider?: Provider - get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) - } - - private _sync?: Sync - get sync(): Sync { - return (this._sync ??= new Sync({ client: this.client })) - } - private _find?: Find get find(): Find { return (this._find ??= new Find({ client: this.client })) @@ -4450,21 +4641,6 @@ export class OpencodeClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } - - private _mcp?: Mcp - get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) - } - - private _tui?: Tui - get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) - } - private _instance?: Instance get instance(): Instance { return (this._instance ??= new Instance({ client: this.client })) @@ -4494,4 +4670,59 @@ export class OpencodeClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } + + private _mcp?: Mcp + get mcp(): Mcp { + return (this._mcp ??= new Mcp({ client: this.client })) + } + + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + + private _pty?: Pty + get pty(): Pty { + return (this._pty ??= new Pty({ client: this.client })) + } + + private _question?: Question + get question(): Question { + return (this._question ??= new Question({ client: this.client })) + } + + private _permission?: Permission + get permission(): Permission { + return (this._permission ??= new Permission({ client: this.client })) + } + + private _provider?: Provider + get provider(): Provider { + return (this._provider ??= new Provider({ client: this.client })) + } + + private _session?: Session2 + get session(): Session2 { + return (this._session ??= new Session2({ client: this.client })) + } + + private _part?: Part + get part(): Part { + return (this._part ??= new Part({ client: this.client })) + } + + private _sync?: Sync + get sync(): Sync { + return (this._sync ??= new Sync({ client: this.client })) + } + + private _v2?: V2 + get v2(): V2 { + return (this._v2 ??= new V2({ client: this.client })) + } + + private _tui?: Tui + get tui(): Tui { + return (this._tui ??= new Tui({ client: this.client })) + } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 31bd40ab4f..caa3d4c767 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,53 +4,104 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string +export type Event = + | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed + +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string +} + +export type ApiAuth = { + type: "api" + key: string + metadata?: { + [key: string]: string } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" - properties: { - serverID: string - path: string - } -} - -export type EventLspUpdated = { - type: "lsp.updated" - properties: { - [key: string]: unknown - } -} - -export type EventMessagePartDelta = { - type: "message.part.delta" - properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } -} +export type Auth = OAuth | ApiAuth | WellKnownAuth export type PermissionRequest = { id: string @@ -67,20 +118,6 @@ export type PermissionRequest = { } } -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type SnapshotFileDiff = { file: string patch: string @@ -89,14 +126,6 @@ export type SnapshotFileDiff = { status?: "added" | "deleted" | "modified" } -export type EventSessionDiff = { - type: "session.diff" - properties: { - sessionID: string - diff: Array - } -} - export type ProviderAuthError = { name: "ProviderAuthError" data: { @@ -158,35 +187,6 @@ export type ApiError = { } } -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - -export type EventInstallationUpdated = { - type: "installation.updated" - properties: { - version: string - } -} - -export type EventInstallationUpdateAvailable = { - type: "installation.update-available" - properties: { - version: string - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -211,13 +211,7 @@ export type QuestionInfo = { * Available choices */ options: Array - /** - * Allow selecting multiple choices - */ multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ custom?: boolean } @@ -236,11 +230,6 @@ export type QuestionRequest = { tool?: QuestionTool } -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - export type QuestionAnswer = Array export type QuestionReplied = { @@ -249,21 +238,11 @@ export type QuestionReplied = { answers: Array } -export type EventQuestionReplied = { - type: "question.replied" - properties: QuestionReplied -} - export type QuestionRejected = { sessionID: string requestID: string } -export type EventQuestionRejected = { - type: "question.rejected" - properties: QuestionRejected -} - export type Todo = { /** * Brief description of the task @@ -279,14 +258,6 @@ export type Todo = { priority: string } -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type SessionStatus = | { type: "idle" @@ -301,29 +272,8 @@ export type SessionStatus = type: "busy" } -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - export type EventTuiPromptAppend = { + id: string type: "tui.prompt.append" properties: { text: string @@ -331,6 +281,7 @@ export type EventTuiPromptAppend = { } export type EventTuiCommandExecute = { + id: string type: "tui.command.execute" properties: { command: @@ -355,19 +306,18 @@ export type EventTuiCommandExecute = { } export type EventTuiToastShow = { + id: string type: "tui.toast.show" properties: { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } } export type EventTuiSessionSelect = { + id: string type: "tui.session.select" properties: { /** @@ -377,31 +327,6 @@ export type EventTuiSessionSelect = { } } -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string - } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - export type Project = { id: string worktree: string @@ -426,65 +351,6 @@ export type Project = { sandboxes: Array } -export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string - } -} - export type Pty = { id: string title: string @@ -495,35 +361,6 @@ export type Pty = { pid: number } -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - export type OutputFormatText = { type: "text" } @@ -609,22 +446,6 @@ export type AssistantMessage = { export type Message = UserMessage | AssistantMessage -export type EventMessageUpdated = { - type: "message.updated" - properties: { - sessionID: string - info: Message - } -} - -export type EventMessageRemoved = { - type: "message.removed" - properties: { - sessionID: string - messageID: string - } -} - export type TextPart = { id: string sessionID: string @@ -888,24 +709,6 @@ export type Part = | RetryPart | CompactionPart -export type EventMessagePartUpdated = { - type: "message.part.updated" - properties: { - sessionID: string - part: Part - time: number - } -} - -export type EventMessagePartRemoved = { - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string - } -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -934,6 +737,12 @@ export type Session = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -950,160 +759,10 @@ export type Session = { } } -export type EventSessionCreated = { - type: "session.created" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionUpdated = { - type: "session.updated" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionDeleted = { - type: "session.deleted" - properties: { - sessionID: string - info: Session - } -} - -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type SyncEventMessageUpdated = { - type: "sync" - name: "message.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Message - } -} - -export type SyncEventMessageRemoved = { - type: "sync" - name: "message.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - } -} - -export type SyncEventMessagePartUpdated = { - type: "sync" - name: "message.part.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - part: Part - time: number - } -} - -export type SyncEventMessagePartRemoved = { - type: "sync" - name: "message.part.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SyncEventSessionCreated = { - type: "sync" - name: "session.created.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEventSessionUpdated = { - type: "sync" - name: "session.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: { - id?: string | null - slug?: string | null - projectID?: string | null - workspaceID?: string | null - directory?: string | null - path?: string | null - parentID?: string | null - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } | null - share?: { - url?: string | null - } - title?: string | null - version?: string | null - time?: { - created?: number | null - updated?: number | null - compacting?: number | null - archived?: number | null - } - permission?: PermissionRuleset | null - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } | null - } - } -} - -export type SyncEventSessionDeleted = { - type: "sync" - name: "session.deleted.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } +export type Prompt = { + text: string + files?: Array + agents?: Array } export type GlobalEvent = { @@ -1156,6 +815,31 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded | EventServerConnected | EventGlobalDisposed | SyncEventMessageUpdated @@ -1165,6 +849,31 @@ export type GlobalEvent = { | SyncEventSessionCreated | SyncEventSessionUpdated | SyncEventSessionDeleted + | SyncEventSessionNextAgentSwitched + | SyncEventSessionNextModelSwitched + | SyncEventSessionNextPrompted + | SyncEventSessionNextSynthetic + | SyncEventSessionNextShellStarted + | SyncEventSessionNextShellEnded + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextDelta + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputDelta + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolError + | SyncEventSessionNextRetried + | SyncEventSessionNextCompactionStarted + | SyncEventSessionNextCompactionDelta + | SyncEventSessionNextCompactionEnded } /** @@ -1176,25 +885,10 @@ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" * Server configuration for opencode serve and web commands */ export type ServerConfig = { - /** - * Port to listen on - */ port?: number - /** - * Hostname to listen on - */ hostname?: string - /** - * Enable mDNS service discovery - */ mdns?: boolean - /** - * Custom domain name for mDNS service (default: opencode.local) - */ mdnsDomain?: string - /** - * Additional domains to allow for CORS - */ cors?: Array } @@ -1229,28 +923,16 @@ export type PermissionConfig = export type AgentConfig = { model?: string - /** - * Default model variant for this agent (applies only when using the agent's configured model). - */ variant?: string temperature?: number top_p?: number prompt?: string - /** - * @deprecated Use 'permission' field instead - */ tools?: { [key: string]: boolean } disable?: boolean - /** - * Description of when to use the agent - */ description?: string mode?: "subagent" | "primary" | "all" - /** - * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) - */ hidden?: boolean options?: { [key: string]: unknown @@ -1259,13 +941,7 @@ export type AgentConfig = { * Hex color code (e.g., #FF5733) or theme color (e.g., primary) */ color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - /** - * Maximum number of agentic iterations before forcing text-only response - */ steps?: number - /** - * @deprecated Use 'steps' field instead. - */ maxSteps?: number permission?: PermissionConfig [key: string]: @@ -1306,21 +982,12 @@ export type ProviderConfig = { options?: { apiKey?: string baseURL?: string - /** - * GitHub Enterprise URL for copilot authentication - */ enterpriseUrl?: string - /** - * Enable promptCacheKey for this provider (default false) - */ setCacheKey?: boolean /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ timeout?: number | false - /** - * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. - */ chunkTimeout?: number [key: string]: unknown | string | boolean | number | false | number | undefined } @@ -1377,9 +1044,6 @@ export type ProviderConfig = { */ variants?: { [key: string]: { - /** - * Disable this variant for the model - */ disabled?: boolean [key: string]: unknown | boolean | undefined } @@ -1397,38 +1061,17 @@ export type McpLocalConfig = { * Command and arguments to run the MCP server */ command: Array - /** - * Environment variables to set when running the MCP server - */ environment?: { [key: string]: string } - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } export type McpOAuthConfig = { - /** - * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. - */ clientId?: string - /** - * OAuth client secret (if required by the authorization server) - */ clientSecret?: string - /** - * OAuth scopes to request during authorization - */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ redirectUri?: string } @@ -1441,13 +1084,7 @@ export type McpRemoteConfig = { * URL of the remote MCP server */ url: string - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Headers to send with the request - */ headers?: { [key: string]: string } @@ -1455,9 +1092,6 @@ export type McpRemoteConfig = { * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. */ oauth?: McpOAuthConfig | false - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } @@ -1467,19 +1101,10 @@ export type McpRemoteConfig = { export type LayoutConfig = "auto" | "stretch" export type Config = { - /** - * JSON schema reference for configuration validation - */ $schema?: string - /** - * Default shell to use for terminal and bash tool - */ shell?: string logLevel?: LogLevel server?: ServerConfig - /** - * Command configuration, see https://opencode.ai/docs/commands - */ command?: { [key: string]: { template: string @@ -1489,25 +1114,13 @@ export type Config = { subtask?: boolean } } - /** - * Additional skill folder paths - */ skills?: { - /** - * Additional paths to skill folders - */ paths?: Array - /** - * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) - */ urls?: Array } watcher?: { ignore?: Array } - /** - * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. - */ snapshot?: boolean plugin?: Array< | string @@ -1518,53 +1131,23 @@ export type Config = { }, ] > - /** - * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing - */ share?: "manual" | "auto" | "disabled" - /** - * @deprecated Use 'share' field instead. Share newly created sessions automatically - */ autoshare?: boolean /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications */ autoupdate?: boolean | "notify" - /** - * Disable providers that are loaded automatically - */ disabled_providers?: Array - /** - * When set, ONLY these providers will be enabled. All other providers will be ignored - */ enabled_providers?: Array - /** - * Model to use in the format of provider/model, eg anthropic/claude-2 - */ model?: string - /** - * Small model to use for tasks like title generation in the format of provider/model - */ small_model?: string - /** - * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. - */ default_agent?: string - /** - * Custom username to display in conversations instead of system username - */ username?: string - /** - * @deprecated Use `agent` field instead. - */ mode?: { build?: AgentConfig plan?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Agent configuration, see https://opencode.ai/docs/agents - */ agent?: { plan?: AgentConfig build?: AgentConfig @@ -1575,15 +1158,9 @@ export type Config = { compaction?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Custom provider configurations and model overrides - */ provider?: { [key: string]: ProviderConfig } - /** - * MCP (Model Context Protocol) server configurations - */ mcp?: { [key: string]: | McpLocalConfig @@ -1629,9 +1206,6 @@ export type Config = { } } } - /** - * Additional instruction files or patterns to include - */ instructions?: Array layout?: LayoutConfig permission?: PermissionConfig @@ -1639,121 +1213,29 @@ export type Config = { [key: string]: boolean } enterprise?: { - /** - * Enterprise URL - */ url?: string } - /** - * Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned. - */ tool_output?: { - /** - * Maximum lines of tool output before it is truncated and saved to disk (default: 2000) - */ max_lines?: number - /** - * Maximum bytes of tool output before it is truncated and saved to disk (default: 51200) - */ max_bytes?: number } compaction?: { - /** - * Enable automatic compaction when context is full (default: true) - */ auto?: boolean - /** - * Enable pruning of old tool outputs (default: true) - */ prune?: boolean - /** - * Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2) - */ tail_turns?: number - /** - * Maximum number of tokens from recent turns to preserve verbatim after compaction - */ preserve_recent_tokens?: number - /** - * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. - */ reserved?: number } experimental?: { disable_paste_summary?: boolean - /** - * Enable the batch tool - */ batch_tool?: boolean - /** - * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) - */ openTelemetry?: boolean - /** - * Tools that should only be available to primary agents. - */ primary_tools?: Array - /** - * Continue the agent loop when a tool call is denied - */ continue_loop_on_deny?: boolean - /** - * Timeout in milliseconds for model context protocol (MCP) requests - */ mcp_timeout?: number } } -export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false -} - -export type OAuth = { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} - -export type ApiAuth = { - type: "api" - key: string - metadata?: { - [key: string]: string - } -} - -export type WellKnownAuth = { - type: "wellknown" - key: string - token: string -} - -export type Auth = OAuth | ApiAuth | WellKnownAuth - -export type Workspace = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type Model = { id: string providerID: string @@ -1845,8 +1327,6 @@ export type ConsoleState = { switchableOrgCount: number } -export type ToolIds = Array - export type ToolListItem = { id: string description: string @@ -1855,11 +1335,7 @@ export type ToolListItem = { export type ToolList = Array -export type Worktree = { - name: string - branch: string - directory: string -} +export type ToolIds = Array export type WorktreeCreateInput = { name?: string @@ -1869,6 +1345,12 @@ export type WorktreeCreateInput = { startCommand?: string } +export type Worktree = { + name: string + branch: string + directory: string +} + export type WorktreeRemoveInput = { directory: string } @@ -1901,6 +1383,12 @@ export type GlobalSession = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -1926,93 +1414,6 @@ export type McpResource = { client: string } -export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } -} - -export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} - -export type AgentPartInput = { - id?: string - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string - model?: { - providerID: string - modelID: string - } - command?: string -} - -export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - > -} - -export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} - export type Symbol = { name: string kind: number @@ -2059,88 +1460,6 @@ export type File = { status: "added" | "deleted" | "modified" } -export type Event = - | EventServerInstanceDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventProjectUpdated - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - | EventServerConnected - | EventGlobalDisposed - -export type McpStatusConnected = { - status: "connected" -} - -export type McpStatusDisabled = { - status: "disabled" -} - -export type McpStatusFailed = { - status: "failed" - error: string -} - -export type McpStatusNeedsAuth = { - status: "needs_auth" -} - -export type McpStatusNeedsClientRegistration = { - status: "needs_client_registration" - error: string -} - -export type McpStatus = - | McpStatusConnected - | McpStatusDisabled - | McpStatusFailed - | McpStatusNeedsAuth - | McpStatusNeedsClientRegistration - -export type McpUnsupportedOAuthError = { - error: string -} - export type Path = { home: string state: string @@ -2208,6 +1527,1791 @@ export type FormatterStatus = { enabled: boolean } +export type McpStatusConnected = { + status: "connected" +} + +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + +export type McpUnsupportedOAuthError = { + error: string +} + +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + > +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { + providerID: string + modelID: string + } + command?: string +} + +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type V2SessionMessagesResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type EventTuiPromptAppend2 = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute2 = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow2 = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect2 = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type Workspace = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type SyncEventMessageUpdated = { + type: "sync" + name: "message.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Message + } +} + +export type SyncEventMessageRemoved = { + type: "sync" + name: "message.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + } +} + +export type SyncEventMessagePartUpdated = { + type: "sync" + name: "message.part.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + part: Part + time: number + } +} + +export type SyncEventMessagePartRemoved = { + type: "sync" + name: "message.part.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SyncEventSessionCreated = { + type: "sync" + name: "session.created.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionUpdated = { + type: "sync" + name: "session.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: { + id?: string | null + slug?: string | null + projectID?: string | null + workspaceID?: string | null + directory?: string | null + path?: string | null + parentID?: string | null + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } | null + share?: { + url?: string | null + } + title?: string | null + agent?: string | null + model?: { + id: string + providerID: string + variant?: string + } | null + version?: string | null + time?: { + created?: number | null + updated?: number | null + compacting?: number | null + archived?: number | null + } + permission?: PermissionRuleset | null + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } | null + } + } +} + +export type SyncEventSessionDeleted = { + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionNextAgentSwitched = { + type: "sync" + name: "session.next.agent.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + } +} + +export type SyncEventSessionNextModelSwitched = { + type: "sync" + name: "session.next.model.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type SyncEventSessionNextPrompted = { + type: "sync" + name: "session.next.prompted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type SyncEventSessionNextSynthetic = { + type: "sync" + name: "session.next.synthetic.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextShellStarted = { + type: "sync" + name: "session.next.shell.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type SyncEventSessionNextShellEnded = { + type: "sync" + name: "session.next.shell.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SyncEventSessionNextStepStarted = { + type: "sync" + name: "session.next.step.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type SyncEventSessionNextStepEnded = { + type: "sync" + name: "session.next.step.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type SyncEventSessionNextTextStarted = { + type: "sync" + name: "session.next.text.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + } +} + +export type SyncEventSessionNextTextDelta = { + type: "sync" + name: "session.next.text.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + delta: string + } +} + +export type SyncEventSessionNextTextEnded = { + type: "sync" + name: "session.next.text.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type SyncEventSessionNextToolInputStarted = { + type: "sync" + name: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type SyncEventSessionNextToolInputDelta = { + type: "sync" + name: "session.next.tool.input.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + name: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type SyncEventSessionNextToolCalled = { + type: "sync" + name: "session.next.tool.called.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SyncEventSessionNextToolSuccess = { + type: "sync" + name: "session.next.tool.success.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolError = { + type: "sync" + name: "session.next.tool.error.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextRetried = { + type: "sync" + name: "session.next.retried.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SyncEventSessionNextCompactionStarted = { + type: "sync" + name: "session.next.compaction.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type SyncEventSessionNextCompactionDelta = { + type: "sync" + name: "session.next.compaction.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextCompactionEnded = { + type: "sync" + name: "session.next.compaction.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventLspClientDiagnostics = { + id: string + type: "lsp.client.diagnostics" + properties: { + serverID: string + path: string + } +} + +export type EventLspUpdated = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type EventMessagePartDelta = { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type EventPermissionAsked = { + id: string + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventSessionDiff = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + +export type EventInstallationUpdated = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventQuestionAsked = { + id: string + type: "question.asked" + properties: QuestionRequest +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: QuestionReplied +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: QuestionRejected +} + +export type EventTodoUpdated = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventSessionStatus = { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + id: string + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventMcpToolsChanged = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventProjectUpdated = { + id: string + type: "project.updated" + properties: Project +} + +export type EventVcsBranchUpdated = { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady = { + id: string + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + id: string + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + id: string + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventWorktreeReady = { + id: string + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + id: string + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventPtyCreated = { + id: string + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + id: string + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + id: string + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionNextAgentSwitched = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + agent: string + } +} + +export type EventSessionNextModelSwitched = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type PromptSource = { + start: number + end: number + text: string +} + +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource +} + +export type PromptAgentAttachment = { + name: string + source?: PromptSource +} + +export type EventSessionNextPrompted = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type EventSessionNextSynthetic = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextShellStarted = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type EventSessionNextStepEnded = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type EventSessionNextTextStarted = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextTextDelta = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type EventSessionNextToolInputStarted = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type ToolTextContent = { + type: "text" + text: string +} + +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string +} + +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type EventSessionNextToolSuccess = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolError = { + id: string + type: "session.next.tool.error" + properties: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } +} + +export type EventSessionNextRetried = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextCompactionEnded = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type SessionInfo = { + id: string + parentID?: string + projectID: string + workspaceID?: string + path?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + time: { + created: number + updated: number + archived?: number + } + title: string +} + +export type SessionDelivery = "immediate" | "deferred" + +export type SessionMessageAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: { + id: string + providerID: string + variant?: string + } +} + +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + sessionID: string + text: string + type: "synthetic" +} + +export type SessionMessageShell = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + callID: string + command: string + output: string +} + +export type SessionMessageAssistantText = { + type: "text" + text: string +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + id: string + text: string +} + +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + structured: { + [key: string]: unknown + } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + attachments?: Array + content: Array + structured: { + [key: string]: unknown + } +} + +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + content: Array + structured: { + [key: string]: unknown + } + error: { + type: string + message: string + } +} + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + provider?: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number + } +} + +export type SessionMessageAssistant = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: { + id: string + providerID: string + variant?: string + } + content: Array + snapshot?: { + start?: string + end?: string + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: string +} + +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + include?: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type EventTuiToastShow1 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type BadRequestError = { + data: unknown + errors: Array<{ + [key: string]: unknown + }> + success: false +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean +} + +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + +export type AuthSetData = { + body?: Auth + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthSetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean +} + +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + extra?: { + [key: string]: unknown + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/log" +} + +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean +} + +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + export type GlobalHealthData = { body?: never path?: never @@ -2335,276 +3439,924 @@ export type GlobalUpgradeResponses = { export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] -export type AuthRemoveData = { - body?: never - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] - -export type AuthRemoveResponses = { - /** - * Successfully removed authentication credentials - */ - 200: boolean -} - -export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] - -export type AuthSetData = { - body?: Auth - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthSetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] - -export type AuthSetResponses = { - /** - * Successfully set authentication credentials - */ - 200: boolean -} - -export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] - -export type AppLogData = { - body?: { - /** - * Service name for the log entry - */ - service: string - /** - * Log level - */ - level: "debug" | "info" | "error" | "warn" - /** - * Log message - */ - message: string - /** - * Additional metadata for the log entry - */ - extra?: { - [key: string]: unknown - } - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/log" -} - -export type AppLogErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppLogError = AppLogErrors[keyof AppLogErrors] - -export type AppLogResponses = { - /** - * Log entry written successfully - */ - 200: boolean -} - -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] - -export type ExperimentalWorkspaceAdapterListData = { +export type EventSubscribeData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/adapter" + url: "/event" } -export type ExperimentalWorkspaceAdapterListResponses = { +export type EventSubscribeResponses = { /** - * Workspace adapters + * Event stream */ - 200: Array<{ - type: string - name: string - description: string - }> + 200: Event } -export type ExperimentalWorkspaceAdapterListResponse = - ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] -export type ExperimentalWorkspaceListData = { +export type ConfigGetData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceListResponses = { +export type ConfigGetResponses = { /** - * Workspaces + * Get config info */ - 200: Array + 200: Config } -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] -export type ExperimentalWorkspaceCreateData = { - body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } +export type ConfigUpdateData = { + body?: Config path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceCreateErrors = { +export type ConfigUpdateErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] -export type ExperimentalWorkspaceCreateResponses = { +export type ConfigUpdateResponses = { /** - * Workspace created + * Successfully updated config */ - 200: Workspace + 200: Config } -export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] -export type ExperimentalWorkspaceStatusData = { +export type ConfigProvidersData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/status" + url: "/config/providers" } -export type ExperimentalWorkspaceStatusResponses = { +export type ConfigProvidersResponses = { /** - * Workspace status - */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> -} - -export type ExperimentalWorkspaceStatusResponse = - ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] - -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} - -export type ExperimentalWorkspaceRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] - -export type ExperimentalWorkspaceRemoveResponses = { - /** - * Workspace removed - */ - 200: Workspace -} - -export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] - -export type ExperimentalWorkspaceSessionRestoreData = { - body?: { - sessionID: string - } - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}/session-restore" -} - -export type ExperimentalWorkspaceSessionRestoreErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] - -export type ExperimentalWorkspaceSessionRestoreResponses = { - /** - * Session replay started + * List of providers */ 200: { - total: number + providers: Array + default: { + [key: string]: string + } } } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] + +export type ExperimentalConsoleGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console" +} + +export type ExperimentalConsoleGetResponses = { + /** + * Active Console provider metadata + */ + 200: ConsoleState +} + +export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] + +export type ExperimentalConsoleListOrgsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/orgs" +} + +export type ExperimentalConsoleListOrgsResponses = { + /** + * Switchable Console orgs + */ + 200: { + orgs: Array<{ + accountID: string + accountEmail: string + accountUrl: string + orgID: string + orgName: string + active: boolean + }> + } +} + +export type ExperimentalConsoleListOrgsResponse = + ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] + +export type ExperimentalConsoleSwitchOrgData = { + body?: { + accountID: string + orgID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/switch" +} + +export type ExperimentalConsoleSwitchOrgResponses = { + /** + * Switch success + */ + 200: boolean +} + +export type ExperimentalConsoleSwitchOrgResponse = + ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] + +export type ToolListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + provider: string + model: string + } + url: "/experimental/tool" +} + +export type ToolListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolListError = ToolListErrors[keyof ToolListErrors] + +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + +export type ToolIdsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/tool/ids" +} + +export type ToolIdsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] + +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type WorktreeRemoveData = { + body?: WorktreeRemoveInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] + +export type WorktreeRemoveResponses = { + /** + * Worktree removed + */ + 200: boolean +} + +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] + +export type WorktreeListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeListResponses = { + /** + * List of worktree directories + */ + 200: Array +} + +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] + +export type WorktreeCreateData = { + body?: WorktreeCreateInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] + +export type WorktreeCreateResponses = { + /** + * Worktree created + */ + 200: Worktree +} + +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] + +export type WorktreeResetData = { + body?: WorktreeResetInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree/reset" +} + +export type WorktreeResetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] + +export type WorktreeResetResponses = { + /** + * Worktree reset + */ + 200: boolean +} + +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] + +export type ExperimentalSessionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + } + url: "/experimental/session" +} + +export type ExperimentalSessionListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] + +export type ExperimentalResourceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/resource" +} + +export type ExperimentalResourceListResponses = { + /** + * MCP resources + */ + 200: { + [key: string]: McpResource + } +} + +export type ExperimentalResourceListResponse = + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + +export type FindTextData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + pattern: string + } + url: "/find" +} + +export type FindTextResponses = { + /** + * Matches + */ + 200: Array<{ + path: { + text: string + } + lines: { + text: string + } + line_number: number + absolute_offset: number + submatches: Array<{ + match: { + text: string + } + start: number + end: number + }> + }> +} + +export type FindTextResponse = FindTextResponses[keyof FindTextResponses] + +export type FindFilesData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + } + url: "/find/file" +} + +export type FindFilesResponses = { + /** + * File paths + */ + 200: Array +} + +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] + +export type FindSymbolsData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + } + url: "/find/symbol" +} + +export type FindSymbolsResponses = { + /** + * Symbols + */ + 200: Array +} + +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] + +export type FileListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file" +} + +export type FileListResponses = { + /** + * Files and directories + */ + 200: Array +} + +export type FileListResponse = FileListResponses[keyof FileListResponses] + +export type FileReadData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file/content" +} + +export type FileReadResponses = { + /** + * File content + */ + 200: FileContent +} + +export type FileReadResponse = FileReadResponses[keyof FileReadResponses] + +export type FileStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/file/status" +} + +export type FileStatusResponses = { + /** + * File status + */ + 200: Array +} + +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] + +export type InstanceDisposeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/instance/dispose" +} + +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean +} + +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] + +export type PathGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/path" +} + +export type PathGetResponses = { + /** + * Path + */ + 200: Path +} + +export type PathGetResponse = PathGetResponses[keyof PathGetResponses] + +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs" +} + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + +export type VcsDiffData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + mode: "git" | "branch" + } + url: "/vcs/diff" +} + +export type VcsDiffResponses = { + /** + * VCS diff + */ + 200: Array +} + +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] + +export type CommandListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/command" +} + +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array +} + +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] + +export type AppAgentsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/agent" +} + +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array +} + +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] + +export type AppSkillsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/skill" +} + +export type AppSkillsResponses = { + /** + * List of skills + */ + 200: Array<{ + name: string + description: string + location: string + content: string + }> +} + +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] + +export type LspStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp" +} + +export type LspStatusResponses = { + /** + * LSP server status + */ + 200: Array +} + +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] + +export type FormatterStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/formatter" +} + +export type FormatterStatusResponses = { + /** + * Formatter status + */ + 200: Array +} + +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] + +export type McpStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpStatusResponses = { + /** + * MCP server status + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] + +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] + +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true + } +} + +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] + +export type McpAuthStartData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthStartErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] + +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + authorizationUrl: string + oauthState: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + code: string + } + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/callback" +} + +export type McpAuthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] + +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] + +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/authenticate" +} + +export type McpAuthAuthenticateErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] + +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] + +export type McpConnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/connect" +} + +export type McpConnectResponses = { + /** + * MCP server connected successfully + */ + 200: boolean +} + +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] + +export type McpDisconnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/disconnect" +} + +export type McpDisconnectResponses = { + /** + * MCP server disconnected successfully + */ + 200: boolean +} + +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] export type ProjectListData = { body?: never @@ -2884,439 +4636,285 @@ export type PtyUpdateResponses = { export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] -export type PtyConnectData = { +export type QuestionListData = { body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/question" +} + +export type QuestionListResponses = { + /** + * List of pending questions + */ + 200: Array +} + +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] + +export type QuestionReplyData = { + body?: { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array + } path: { - ptyID: string + requestID: string } query?: { directory?: string workspace?: string } - url: "/pty/{ptyID}/connect" + url: "/question/{requestID}/reply" } -export type PtyConnectErrors = { +export type QuestionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ 404: NotFoundError } -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] -export type PtyConnectResponses = { +export type QuestionReplyResponses = { /** - * Connected session + * Question answered successfully */ 200: boolean } -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] -export type ConfigGetData = { +export type QuestionRejectData = { body?: never - path?: never + path: { + requestID: string + } query?: { directory?: string workspace?: string } - url: "/config" + url: "/question/{requestID}/reject" } -export type ConfigGetResponses = { - /** - * Get config info - */ - 200: Config -} - -export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] - -export type ConfigUpdateData = { - body?: Config - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/config" -} - -export type ConfigUpdateErrors = { +export type QuestionRejectErrors = { /** * Bad request */ 400: BadRequestError -} - -export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] - -export type ConfigUpdateResponses = { /** - * Successfully updated config + * Not found */ - 200: Config + 404: NotFoundError } -export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] -export type ConfigProvidersData = { +export type QuestionRejectResponses = { + /** + * Question rejected successfully + */ + 200: boolean +} + +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] + +export type PermissionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config/providers" + url: "/permission" } -export type ConfigProvidersResponses = { +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + +export type PermissionReplyData = { + body?: { + reply: "once" | "always" | "reject" + message?: string + } + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/permission/{requestID}/reply" +} + +export type PermissionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] + +export type PermissionReplyResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] + +export type ProviderListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/provider" +} + +export type ProviderListResponses = { /** * List of providers */ 200: { - providers: Array + all: Array default: { [key: string]: string } + connected: Array } } -export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] -export type ExperimentalConsoleGetData = { +export type ProviderAuthData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console" + url: "/provider/auth" } -export type ExperimentalConsoleGetResponses = { +export type ProviderAuthResponses = { /** - * Active Console provider metadata - */ - 200: ConsoleState -} - -export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] - -export type ExperimentalConsoleListOrgsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/console/orgs" -} - -export type ExperimentalConsoleListOrgsResponses = { - /** - * Switchable Console orgs + * Provider auth methods */ 200: { - orgs: Array<{ - accountID: string - accountEmail: string - accountUrl: string - orgID: string - orgName: string - active: boolean - }> + [key: string]: Array } } -export type ExperimentalConsoleListOrgsResponse = - ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] -export type ExperimentalConsoleSwitchOrgData = { +export type ProviderOauthAuthorizeData = { body?: { - accountID: string - orgID: string + /** + * Auth method index + */ + method: number + inputs?: { + [key: string]: string + } + } + path: { + providerID: string } - path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console/switch" + url: "/provider/{providerID}/oauth/authorize" } -export type ExperimentalConsoleSwitchOrgResponses = { +export type ProviderOauthAuthorizeErrors = { /** - * Switch success + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number + code?: string + } + path: { + providerID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/provider/{providerID}/oauth/callback" +} + +export type ProviderOauthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully */ 200: boolean } -export type ExperimentalConsoleSwitchOrgResponse = - ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] - -export type ToolIdsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/tool/ids" -} - -export type ToolIdsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] - -export type ToolIdsResponses = { - /** - * Tool IDs - */ - 200: ToolIds -} - -export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] - -export type ToolListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - provider: string - model: string - } - url: "/experimental/tool" -} - -export type ToolListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolListError = ToolListErrors[keyof ToolListErrors] - -export type ToolListResponses = { - /** - * Tools - */ - 200: ToolList -} - -export type ToolListResponse = ToolListResponses[keyof ToolListResponses] - -export type WorktreeRemoveData = { - body?: WorktreeRemoveInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] - -export type WorktreeRemoveResponses = { - /** - * Worktree removed - */ - 200: boolean -} - -export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] - -export type WorktreeListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeListResponses = { - /** - * List of worktree directories - */ - 200: Array -} - -export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] - -export type WorktreeCreateData = { - body?: WorktreeCreateInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] - -export type WorktreeCreateResponses = { - /** - * Worktree created - */ - 200: Worktree -} - -export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] - -export type WorktreeResetData = { - body?: WorktreeResetInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree/reset" -} - -export type WorktreeResetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] - -export type WorktreeResetResponses = { - /** - * Worktree reset - */ - 200: boolean -} - -export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] - -export type ExperimentalSessionListData = { - body?: never - path?: never - query?: { - /** - * Filter sessions by project directory - */ - directory?: string - workspace?: string - /** - * Only return root sessions (no parentID) - */ - roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ - start?: number - /** - * Return sessions updated before this timestamp (milliseconds since epoch) - */ - cursor?: number - /** - * Filter sessions by title (case-insensitive) - */ - search?: string - /** - * Maximum number of sessions to return - */ - limit?: number - /** - * Include archived sessions (default false) - */ - archived?: boolean | "true" | "false" - } - url: "/experimental/session" -} - -export type ExperimentalSessionListResponses = { - /** - * List of sessions - */ - 200: Array -} - -export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] - -export type ExperimentalResourceListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/resource" -} - -export type ExperimentalResourceListResponses = { - /** - * MCP resources - */ - 200: { - [key: string]: McpResource - } -} - -export type ExperimentalResourceListResponse = - ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] export type SessionListData = { body?: never path?: never query?: { - /** - * Filter sessions by directory - */ directory?: string workspace?: string - /** - * List all sessions for the current project - */ scope?: "project" - /** - * Filter sessions by project-relative path - */ path?: string - /** - * Only return root sessions (no parentID) - */ roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ start?: number - /** - * Filter sessions by title (case-insensitive) - */ search?: string - /** - * Maximum number of sessions to return - */ limit?: number } url: "/session" @@ -3335,6 +4933,12 @@ export type SessionCreateData = { body?: { parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string } @@ -3570,169 +5174,6 @@ export type SessionTodoResponses = { export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] -export type SessionInitData = { - body?: { - modelID: string - providerID: string - messageID: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/init" -} - -export type SessionInitErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] - -export type SessionInitResponses = { - /** - * 200 - */ - 200: boolean -} - -export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] - -export type SessionForkData = { - body?: { - messageID?: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/fork" -} - -export type SessionForkResponses = { - /** - * 200 - */ - 200: Session -} - -export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] - -export type SessionAbortData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/abort" -} - -export type SessionAbortErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] - -export type SessionAbortResponses = { - /** - * Aborted session - */ - 200: boolean -} - -export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] - -export type SessionUnshareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionUnshareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] - -export type SessionUnshareResponses = { - /** - * Successfully unshared session - */ - 200: Session -} - -export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] - -export type SessionShareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionShareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] - -export type SessionShareResponses = { - /** - * Successfully shared session - */ - 200: Session -} - -export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] - export type SessionDiffData = { body?: never path: { @@ -3755,44 +5196,6 @@ export type SessionDiffResponses = { export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] -export type SessionSummarizeData = { - body?: { - providerID: string - modelID: string - auto?: boolean - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/summarize" -} - -export type SessionSummarizeErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] - -export type SessionSummarizeResponses = { - /** - * Summarized session - */ - 200: boolean -} - -export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] - export type SessionMessagesData = { body?: never path: { @@ -3801,9 +5204,6 @@ export type SessionMessagesData = { query?: { directory?: string workspace?: string - /** - * Maximum number of messages to return - */ limit?: number before?: string } @@ -3844,9 +5244,6 @@ export type SessionPromptData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -3963,21 +5360,42 @@ export type SessionMessageResponses = { export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] -export type PartDeleteData = { - body?: never +export type SessionForkData = { + body?: { + messageID?: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/fork" } -export type PartDeleteErrors = { +export type SessionForkResponses = { + /** + * 200 + */ + 200: Session +} + +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] + +export type SessionAbortData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/abort" +} + +export type SessionAbortErrors = { /** * Bad request */ @@ -3988,32 +5406,34 @@ export type PartDeleteErrors = { 404: NotFoundError } -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] -export type PartDeleteResponses = { +export type SessionAbortResponses = { /** - * Successfully deleted part + * Aborted session */ 200: boolean } -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] -export type PartUpdateData = { - body?: Part +export type SessionInitData = { + body?: { + modelID: string + providerID: string + messageID: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/init" } -export type PartUpdateErrors = { +export type SessionInitErrors = { /** * Bad request */ @@ -4024,16 +5444,122 @@ export type PartUpdateErrors = { 404: NotFoundError } -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] -export type PartUpdateResponses = { +export type SessionInitResponses = { /** - * Successfully updated part + * 200 */ - 200: Part + 200: boolean } -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] + +export type SessionUnshareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionUnshareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] + +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session +} + +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] + +export type SessionShareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionShareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] + +export type SessionShareResponses = { + /** + * Successfully shared session + */ + 200: Session +} + +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] + +export type SessionSummarizeData = { + body?: { + providerID: string + modelID: string + auto?: boolean + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/summarize" +} + +export type SessionSummarizeErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] + +export type SessionSummarizeResponses = { + /** + * Summarized session + */ + 200: boolean +} + +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] export type SessionPromptAsyncData = { body?: { @@ -4044,9 +5570,6 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -4292,22 +5815,21 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] -export type PermissionReplyData = { - body?: { - reply: "once" | "always" | "reject" - message?: string - } +export type PartDeleteData = { + body?: never path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/permission/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type PermissionReplyErrors = { +export type PartDeleteErrors = { /** * Bad request */ @@ -4318,73 +5840,32 @@ export type PermissionReplyErrors = { 404: NotFoundError } -export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] -export type PermissionReplyResponses = { +export type PartDeleteResponses = { /** - * Permission processed successfully + * Successfully deleted part */ 200: boolean } -export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] -export type PermissionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/permission" -} - -export type PermissionListResponses = { - /** - * List of pending permissions - */ - 200: Array -} - -export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] - -export type QuestionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/question" -} - -export type QuestionListResponses = { - /** - * List of pending questions - */ - 200: Array -} - -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] - -export type QuestionReplyData = { - body?: { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array - } +export type PartUpdateData = { + body?: Part path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/question/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type QuestionReplyErrors = { +export type PartUpdateErrors = { /** * Bad request */ @@ -4395,182 +5876,16 @@ export type QuestionReplyErrors = { 404: NotFoundError } -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] -export type QuestionReplyResponses = { +export type PartUpdateResponses = { /** - * Question answered successfully + * Successfully updated part */ - 200: boolean + 200: Part } -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] - -export type QuestionRejectData = { - body?: never - path: { - requestID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reject" -} - -export type QuestionRejectErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] - -export type QuestionRejectResponses = { - /** - * Question rejected successfully - */ - 200: boolean -} - -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] - -export type ProviderListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider" -} - -export type ProviderListResponses = { - /** - * List of providers - */ - 200: { - all: Array - default: { - [key: string]: string - } - connected: Array - } -} - -export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] - -export type ProviderAuthData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider/auth" -} - -export type ProviderAuthResponses = { - /** - * Provider auth methods - */ - 200: { - [key: string]: Array - } -} - -export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] - -export type ProviderOauthAuthorizeData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * Prompt inputs - */ - inputs?: { - [key: string]: string - } - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/authorize" -} - -export type ProviderOauthAuthorizeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] - -export type ProviderOauthAuthorizeResponses = { - /** - * Authorization URL and method - */ - 200: ProviderAuthAuthorization -} - -export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] - -export type ProviderOauthCallbackData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * OAuth authorization code - */ - code?: string - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/callback" -} - -export type ProviderOauthCallbackErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] - -export type ProviderOauthCallbackResponses = { - /** - * OAuth callback processed successfully - */ - 200: boolean -} - -export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] export type SyncStartData = { body?: never @@ -4670,402 +5985,150 @@ export type SyncHistoryListResponses = { export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] -export type FindTextData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - pattern: string - } - url: "/find" -} - -export type FindTextResponses = { - /** - * Matches - */ - 200: Array<{ - path: { - text: string - } - lines: { - text: string - } - line_number: number - absolute_offset: number - submatches: Array<{ - match: { - text: string - } - start: number - end: number - }> - }> -} - -export type FindTextResponse = FindTextResponses[keyof FindTextResponses] - -export type FindFilesData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - } - url: "/find/file" -} - -export type FindFilesResponses = { - /** - * File paths - */ - 200: Array -} - -export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] - -export type FindSymbolsData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - } - url: "/find/symbol" -} - -export type FindSymbolsResponses = { - /** - * Symbols - */ - 200: Array -} - -export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] - -export type FileListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file" -} - -export type FileListResponses = { - /** - * Files and directories - */ - 200: Array -} - -export type FileListResponse = FileListResponses[keyof FileListResponses] - -export type FileReadData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file/content" -} - -export type FileReadResponses = { - /** - * File content - */ - 200: FileContent -} - -export type FileReadResponse = FileReadResponses[keyof FileReadResponses] - -export type FileStatusData = { +export type V2SessionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/file/status" + url: "/api/session" } -export type FileStatusResponses = { - /** - * File status - */ - 200: Array -} - -export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] - -export type EventSubscribeData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/event" -} - -export type EventSubscribeResponses = { - /** - * Event stream - */ - 200: Event -} - -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] - -export type McpStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpStatusResponses = { - /** - * MCP server status - */ - 200: { - [key: string]: McpStatus - } -} - -export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] - -export type McpAddData = { - body?: { - name: string - config: McpLocalConfig | McpRemoteConfig - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpAddErrors = { +export type V2SessionListErrors = { /** * Bad request */ 400: BadRequestError } -export type McpAddError = McpAddErrors[keyof McpAddErrors] +export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] -export type McpAddResponses = { +export type V2SessionListResponses = { /** - * MCP server added successfully + * V2SessionsResponse */ - 200: { - [key: string]: McpStatus - } + 200: V2SessionsResponse } -export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] -export type McpAuthRemoveData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthRemoveErrors = { - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] - -export type McpAuthRemoveResponses = { - /** - * OAuth credentials removed - */ - 200: { - success: true - } -} - -export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] - -export type McpAuthStartData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthStartErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] - -export type McpAuthStartResponses = { - /** - * OAuth flow started - */ - 200: { - /** - * URL to open in browser for authorization - */ - authorizationUrl: string - } -} - -export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] - -export type McpAuthCallbackData = { +export type V2SessionPromptData = { body?: { - /** - * Authorization code from OAuth callback - */ - code: string + prompt: Prompt + delivery?: SessionDelivery } path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth/callback" + url: "/api/session/{sessionID}/prompt" } -export type McpAuthCallbackErrors = { +export type V2SessionPromptResponses = { + /** + * Session.Message + */ + 200: SessionMessage +} + +export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] + +export type V2SessionCompactData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/compact" +} + +export type V2SessionCompactResponses = { + /** + * + */ + 204: void +} + +export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] + +export type V2SessionWaitData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/wait" +} + +export type V2SessionWaitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] + +export type V2SessionContextData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/context" +} + +export type V2SessionContextResponses = { + /** + * Success + */ + 200: Array +} + +export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] + +export type V2SessionMessagesData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/message" +} + +export type V2SessionMessagesErrors = { /** * Bad request */ 400: BadRequestError +} + +export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] + +export type V2SessionMessagesResponses = { /** - * Not found + * V2SessionMessagesResponse */ - 404: NotFoundError + 200: V2SessionMessagesResponse } -export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] - -export type McpAuthCallbackResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] - -export type McpAuthAuthenticateData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/authenticate" -} - -export type McpAuthAuthenticateErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] - -export type McpAuthAuthenticateResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] - -export type McpConnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/connect" -} - -export type McpConnectResponses = { - /** - * MCP server connected successfully - */ - 200: boolean -} - -export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] - -export type McpDisconnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/disconnect" -} - -export type McpDisconnectResponses = { - /** - * MCP server disconnected successfully - */ - 200: boolean -} - -export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] export type TuiAppendPromptData = { body?: { @@ -5246,9 +6309,6 @@ export type TuiShowToastData = { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } path?: never @@ -5269,7 +6329,7 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 path?: never query?: { directory?: string @@ -5374,179 +6434,202 @@ export type TuiControlResponseResponses = { export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] -export type InstanceDisposeData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/instance/dispose" + url: "/experimental/workspace/adapter" } -export type InstanceDisposeResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Instance disposed + * Workspace adapters + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch: string | null + extra?: unknown | null + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceSessionRestoreData = { + body?: { + sessionID: string + } + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}/session-restore" +} + +export type ExperimentalWorkspaceSessionRestoreErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceSessionRestoreError = + ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] + +export type ExperimentalWorkspaceSessionRestoreResponses = { + /** + * Session replay started + */ + 200: { + total: number + } +} + +export type ExperimentalWorkspaceSessionRestoreResponse = + ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] + +export type PtyConnectData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect" +} + +export type PtyConnectErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session */ 200: boolean } -export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] - -export type PathGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/path" -} - -export type PathGetResponses = { - /** - * Path - */ - 200: Path -} - -export type PathGetResponse = PathGetResponses[keyof PathGetResponses] - -export type VcsGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/vcs" -} - -export type VcsGetResponses = { - /** - * VCS info - */ - 200: VcsInfo -} - -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] - -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} - -export type VcsDiffResponses = { - /** - * VCS diff - */ - 200: Array -} - -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] - -export type CommandListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/command" -} - -export type CommandListResponses = { - /** - * List of commands - */ - 200: Array -} - -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] - -export type AppAgentsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/agent" -} - -export type AppAgentsResponses = { - /** - * List of agents - */ - 200: Array -} - -export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] - -export type AppSkillsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/skill" -} - -export type AppSkillsResponses = { - /** - * List of skills - */ - 200: Array<{ - name: string - description: string - location: string - content: string - }> -} - -export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] - -export type LspStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/lsp" -} - -export type LspStatusResponses = { - /** - * LSP server status - */ - 200: Array -} - -export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] - -export type FormatterStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/formatter" -} - -export type FormatterStatusResponses = { - /** - * Formatter status - */ - 200: Array -} - -export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md new file mode 100644 index 0000000000..20d84c8f47 --- /dev/null +++ b/specs/v2/session-concepts-gap.md @@ -0,0 +1,131 @@ +# Session V2 Concept Gaps + +Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. + +## Message Metadata + +- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. +- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. + +## Output Format + +- Text output format. +- JSON-schema output format. +- Structured-output retry count. +- Structured assistant result payload. +- Structured-output error classification. + +## Errors + +- Aborted error. +- Provider auth error. +- API error with status, retryability, headers, body, and metadata. +- Context-overflow error. +- Output-length error. +- Unknown error. +- V2 mostly reduces assistant errors to strings, except retry errors. + +## Part Identity + +- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. +- V2 assistant content does not preserve stable per-content IDs. +- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. + +## Part Timing And Metadata + +- V1 text, reasoning, and tool states carry timing and provider metadata. +- V2 assistant text and reasoning content only store text. +- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. + +## Snapshots And Patches + +- Snapshot parts. +- Patch parts. +- Step-start snapshot references. +- Step-finish snapshot references. +- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. + +## Step Boundaries + +- V1 stores `step-start` and `step-finish` as first-class parts. +- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. +- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. + +## Compaction + +- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. +- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. +- V1 also has history filtering semantics around completed summary messages and retained tails. + +## Files And Sources + +- V1 file parts have `mime`, `filename`, `url`, and typed source information. +- V1 source variants include file, symbol, and resource sources. +- Symbol sources include LSP range, name, and kind. +- Resource sources include client name and URI. +- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. + +## Agents And Subtasks + +- Agent parts. +- Subtask parts. +- Subtask prompt, description, agent, model, and command. +- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. + +## Text Flags + +- Synthetic text flag. +- Ignored text flag. +- V2 has a separate synthetic entry, but no ignored text concept. + +## Tool Calls + +- V1 pending tool state stores parsed input and raw input text separately. +- V2 pending tool state stores a string input but does not preserve a separate raw field. +- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. +- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. +- V1 error tool state has `time.start` and `time.end`. +- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. +- V1 tracks provider execution and provider call metadata. +- V2 events include provider info, but `SessionEntryStepper` drops it from entries. +- V1 has tool-output compaction and truncation behavior via `time.compacted`. + +## Media Handling + +- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. +- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. +- V2 has attachments but not these model-message conversion semantics. + +## Retries + +- V1 stores retries as independently addressable retry parts. +- V2 stores retries as an assistant aggregate. +- V2 captures some retry information, but not the independent part identity/update model. + +## Processor Control Flow + +- Session status transitions: busy, retry, and idle. +- Retry policy integration. +- Context-overflow-driven compaction. +- Abort and interrupt handling. +- Permission-denied blocking. +- Doom-loop detection. +- Plugin hook for `experimental.text.complete`. +- Background summary generation after steps. +- Cleanup semantics for open text, reasoning, and tool calls. + +## Sync And Bus Events + +- Message updated. +- Message removed. +- Message part updated. +- Message part delta. +- Message part removed. +- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. + +## History Retrieval + +- Cursor encoding and decoding. +- Paged message retrieval. +- Reverse streaming through history. +- Compaction-aware history filtering. From a6cadba81432997fb3ca5c848f7586c6f7b8d48b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:10:52 +0000 Subject: [PATCH 11/19] chore: generate --- .../snapshot.json | 144 +- .../snapshot.json | 142 +- .../20260501142318_next_venus/snapshot.json | 144 +- .../src/server/routes/instance/event.ts | 8 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.ts | 6 +- .../src/v2/session-message-updater.ts | 4 +- .../test/server/httpapi-session.test.ts | 3 +- packages/sdk/openapi.json | 2798 ++++++++++++++++- 9 files changed, 2875 insertions(+), 376 deletions(-) diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json index bb6d06237e..a237b4156e 100644 --- a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "61f807f9-6398-4067-be05-804acc2561bc", - "prevIds": [ - "66cbe0d7-def0-451b-b88a-7608513a9b44" - ], + "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], "ddl": [ { "name": "account_state", @@ -1043,13 +1041,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1058,13 +1052,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1073,13 +1063,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1088,13 +1074,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1103,13 +1085,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1118,13 +1096,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1133,13 +1107,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1148,13 +1118,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1163,13 +1129,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1178,13 +1140,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1193,128 +1151,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1478,4 +1406,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index 1f3bc493c1..740ba0e254 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": [ - "61f807f9-6398-4067-be05-804acc2561bc" - ], + "prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"], "ddl": [ { "name": "account_state", @@ -1053,13 +1051,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1068,13 +1062,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1083,13 +1073,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1098,13 +1084,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1113,13 +1095,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1128,13 +1106,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1143,13 +1117,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1158,13 +1128,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1173,13 +1139,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1188,13 +1150,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1203,128 +1161,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json index e594de2f04..1eb0cf0b07 100644 --- a/packages/opencode/migration/20260501142318_next_venus/snapshot.json +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", - "prevIds": [ - "aaa2ebeb-caa4-478d-8365-4fc595d16856" - ], + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], "ddl": [ { "name": "account_state", @@ -1073,13 +1071,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1088,13 +1082,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1103,13 +1093,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1118,13 +1104,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1133,13 +1115,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1148,13 +1126,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1163,13 +1137,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1178,13 +1148,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1193,13 +1159,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1208,13 +1170,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1223,128 +1181,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1508,4 +1436,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 52e9bc1964..aeb1da5393 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -51,10 +51,10 @@ export const EventRoutes = () => // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { q.push( - JSON.stringify({ - id: Bus.createID(), - type: "server.heartbeat", - properties: {}, + JSON.stringify({ + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, }), ) }, 10_000) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1a32a656d1..e2a47f1800 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -377,7 +377,7 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Success.Sync, { + EventV2.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, structured: value.output.metadata, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index fedfa8996e..09d2c8c3c3 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -81,7 +81,11 @@ export function fromRow(row: SessionRow): Info { title: row.title, agent: row.agent ?? undefined, model: row.model - ? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant } + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } : undefined, version: row.version, summary, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 844f6fe2d1..ad1aa32e70 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -89,9 +89,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve assistant?.content.findLast((item): item is DraftText => item.type === "text") const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => - assistant?.content.findLast( - (item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID, - ) + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) SessionEvent.All.match(event, { "session.next.agent.switched": (event) => { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d96347bed8..c9a0b53bb4 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -242,7 +242,8 @@ describe("session HttpApi", () => { ) expect( - (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })).items, + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) + .items, ).toMatchObject([{ type: "assistant" }]) }), ), diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 208346325b..b1c4ec1d76 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2461,6 +2461,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -7595,6 +7613,9 @@ "Event.server.instance.disposed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "server.instance.disposed" @@ -7609,11 +7630,14 @@ "required": ["directory"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.file.edited": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "file.edited" @@ -7628,11 +7652,14 @@ "required": ["file"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.file.watcher.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "file.watcher.updated" @@ -7651,11 +7678,14 @@ "required": ["file", "event"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "lsp.client.diagnostics" @@ -7673,11 +7703,14 @@ "required": ["serverID", "path"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.lsp.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "lsp.updated" @@ -7687,11 +7720,14 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.part.delta": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.delta" @@ -7721,7 +7757,7 @@ "required": ["sessionID", "messageID", "partID", "field", "delta"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "PermissionRequest": { "type": "object", @@ -7775,6 +7811,9 @@ "Event.permission.asked": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "permission.asked" @@ -7783,11 +7822,14 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.permission.replied": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "permission.replied" @@ -7811,7 +7853,7 @@ "required": ["sessionID", "requestID", "reply"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SnapshotFileDiff": { "type": "object", @@ -7842,6 +7884,9 @@ "Event.session.diff": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.diff" @@ -7863,7 +7908,7 @@ "required": ["sessionID", "diff"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "ProviderAuthError": { "type": "object", @@ -8036,6 +8081,9 @@ "Event.session.error": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.error" @@ -8075,11 +8123,14 @@ } } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.installation.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "installation.updated" @@ -8094,11 +8145,14 @@ "required": ["version"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.installation.update-available": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "installation.update-available" @@ -8113,7 +8167,7 @@ "required": ["version"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionOption": { "type": "object", @@ -8198,6 +8252,9 @@ "Event.question.asked": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.asked" @@ -8206,7 +8263,7 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionAnswer": { "type": "array", @@ -8237,6 +8294,9 @@ "Event.question.replied": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.replied" @@ -8245,7 +8305,7 @@ "$ref": "#/components/schemas/QuestionReplied" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionRejected": { "type": "object", @@ -8264,6 +8324,9 @@ "Event.question.rejected": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.rejected" @@ -8272,7 +8335,7 @@ "$ref": "#/components/schemas/QuestionRejected" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Todo": { "type": "object", @@ -8295,6 +8358,9 @@ "Event.todo.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "todo.updated" @@ -8316,7 +8382,7 @@ "required": ["sessionID", "todos"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SessionStatus": { "anyOf": [ @@ -8368,6 +8434,9 @@ "Event.session.status": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.status" @@ -8386,11 +8455,14 @@ "required": ["sessionID", "status"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.idle": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.idle" @@ -8406,11 +8478,14 @@ "required": ["sessionID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.compacted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.compacted" @@ -8426,7 +8501,7 @@ "required": ["sessionID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.tui.prompt.append": { "type": "object", @@ -8547,6 +8622,9 @@ "Event.mcp.tools.changed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "mcp.tools.changed" @@ -8561,11 +8639,14 @@ "required": ["server"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.mcp.browser.open.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "mcp.browser.open.failed" @@ -8583,11 +8664,14 @@ "required": ["mcpName", "url"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.command.executed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "command.executed" @@ -8613,7 +8697,7 @@ "required": ["name", "sessionID", "arguments", "messageID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Project": { "type": "object", @@ -8687,6 +8771,9 @@ "Event.project.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "project.updated" @@ -8695,11 +8782,14 @@ "$ref": "#/components/schemas/Project" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.vcs.branch.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "vcs.branch.updated" @@ -8713,11 +8803,14 @@ } } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.ready": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.ready" @@ -8732,11 +8825,14 @@ "required": ["name"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.failed" @@ -8751,11 +8847,14 @@ "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.restore": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.restore" @@ -8785,11 +8884,14 @@ "required": ["workspaceID", "sessionID", "total", "step"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.status": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.status" @@ -8809,11 +8911,14 @@ "required": ["workspaceID", "status"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.worktree.ready": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "worktree.ready" @@ -8831,11 +8936,14 @@ "required": ["name", "branch"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.worktree.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "worktree.failed" @@ -8850,7 +8958,7 @@ "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Pty": { "type": "object", @@ -8889,6 +8997,9 @@ "Event.pty.created": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.created" @@ -8903,11 +9014,14 @@ "required": ["info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.updated" @@ -8922,11 +9036,14 @@ "required": ["info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.exited": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.exited" @@ -8947,11 +9064,14 @@ "required": ["id", "exitCode"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.deleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.deleted" @@ -8967,7 +9087,7 @@ "required": ["id"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "OutputFormatText": { "type": "object", @@ -9263,6 +9383,9 @@ "Event.message.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.updated" @@ -9281,11 +9404,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.removed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.removed" @@ -9305,7 +9431,7 @@ "required": ["sessionID", "messageID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "TextPart": { "type": "object", @@ -10147,6 +10273,9 @@ "Event.message.part.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.updated" @@ -10170,11 +10299,14 @@ "required": ["sessionID", "part", "time"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.part.removed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.removed" @@ -10198,7 +10330,7 @@ "required": ["sessionID", "messageID", "partID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "PermissionAction": { "type": "string", @@ -10291,6 +10423,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "version": { "type": "string" }, @@ -10347,6 +10497,9 @@ "Event.session.created": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.created" @@ -10365,11 +10518,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.updated" @@ -10388,11 +10544,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.deleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.deleted" @@ -10411,11 +10570,1082 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] + }, + "Event.session.next.agent.switched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.agent.switched" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.model.switched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.model.switched" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Prompt.Source": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"] + }, + "Prompt.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["uri", "mime"] + }, + "Prompt.AgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["name"] + }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": ["text"] + }, + "Event.session.next.prompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.prompted" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.synthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.synthetic" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.shell.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.shell.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.shell.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.step.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.step.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.step.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.step.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "output": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reasoning": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "write": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["timestamp", "sessionID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.called": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.called" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "Tool.TextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"] + }, + "Tool.FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"] + }, + "Event.session.next.tool.progress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.progress" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.success": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.success" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.error" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"] + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "session.next.retry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"] + }, + "Event.session.next.retried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.retried" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "attempt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] }, "Event.server.connected": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "server.connected" @@ -10425,11 +11655,14 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.global.disposed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "global.disposed" @@ -10439,7 +11672,7 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SyncEvent.message.updated": { "type": "object", @@ -10800,6 +12033,38 @@ } ] }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + { + "type": "null" + } + ] + }, "version": { "anyOf": [ { @@ -10943,6 +12208,1210 @@ }, "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, + "SyncEvent.session.next.agent.switched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.agent.switched.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.model.switched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.model.switched.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.prompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.prompted.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.synthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.synthetic.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.shell.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.shell.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.shell.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.shell.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.step.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.step.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.step.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.step.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "output": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reasoning": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "write": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["timestamp", "sessionID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.called": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.called.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.progress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.progress.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.success": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.success.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.error.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"] + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.retried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.retried.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "attempt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, "GlobalEvent": { "type": "object", "properties": { @@ -11092,6 +13561,81 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.prompted" + }, + { + "$ref": "#/components/schemas/Event.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/Event.session.next.retried" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.ended" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -11118,6 +13662,81 @@ }, { "$ref": "#/components/schemas/SyncEvent.session.deleted" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.prompted" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.step.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.retried" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.ended" } ] } @@ -12749,6 +15368,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "version": { "type": "string" }, @@ -13387,6 +16024,81 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.prompted" + }, + { + "$ref": "#/components/schemas/Event.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/Event.session.next.retried" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.ended" + }, { "$ref": "#/components/schemas/Event.server.connected" }, From ad05a46d747bad0c03a511ccef1115ee95a997c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:26:54 -0400 Subject: [PATCH 12/19] refactor(lifecycle): bootstrap as pure orchestration (#25510) --- packages/opencode/src/file/watcher.ts | 6 ++-- packages/opencode/src/project/bootstrap.ts | 18 +++++------- packages/opencode/src/project/project.ts | 29 ++++++++++++++++++- .../opencode/test/project/project.test.ts | 2 ++ 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index b68c3a3356..146d7b4d07 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -123,7 +123,9 @@ export const layer = Layer.effect( const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]) + yield* Effect.forkScoped( + subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]), + ) } if (ctx.project.vcs === "git") { @@ -135,7 +137,7 @@ export const layer = Layer.effect( const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) - yield* subscribe(vcsDir, ignore) + yield* Effect.forkScoped(subscribe(vcsDir, ignore)) } } }, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ea2aa2e848..fb3e1bb32d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -6,7 +6,6 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { Bus } from "../bus" -import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" @@ -23,13 +22,13 @@ export const layer = Layer.effect( // Yield each bootstrap dep at layer init so `run` itself has R = never. // InstanceStore imports only the lightweight tag from bootstrap-service.ts, // so it can depend on bootstrap without importing this implementation graph. - const bus = yield* Bus.Service const config = yield* Config.Service const file = yield* File.Service const fileWatcher = yield* FileWatcher.Service const format = yield* Format.Service const lsp = yield* LSP.Service const plugin = yield* Plugin.Service + const project = yield* Project.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -41,16 +40,13 @@ export const layer = Layer.effect( yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() - yield* Effect.all( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())), + // Each service self-manages its own slow work via Effect.forkScoped against + // its per-instance state scope. We just await materialization here. + yield* Effect.forEach( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), + { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) - - const projectID = ctx.project.id - yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }) }).pipe(Effect.withSpan("InstanceBootstrap")) return Service.of({ run }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f30d2e90c7..a2c1a097b1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Bus } from "@/bus" +import { Command } from "@/command" +import { InstanceState } from "@/effect/instance-state" import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" @@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable Effect.Effect readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect @@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service > = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const bus = yield* Bus.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -417,6 +427,21 @@ export const layer: Layer.Layer< ) }) + const initState = yield* InstanceState.make( + Effect.fn("Project.initState")(function* (ctx) { + yield* bus.subscribe(Command.Event.Executed).pipe( + Stream.runForEach((payload) => + payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void, + ), + Effect.forkScoped, + ) + }), + ) + + const init = Effect.fn("Project.init")(function* () { + yield* InstanceState.get(initState) + }) + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] @@ -466,6 +491,7 @@ export const layer: Layer.Layer< }) return Service.of({ + init, fromDirectory, discover, list, @@ -481,6 +507,7 @@ export const layer: Layer.Layer< ) export const defaultLayer = layer.pipe( + Layer.provide(Bus.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e69b8e6df2..9906b31645 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Bus } from "@/bus" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" @@ -63,6 +64,7 @@ function mockGitFailure(failArg: string) { function projectLayerWithFailure(failArg: string) { return Project.layer.pipe( Layer.provide(mockGitFailure(failArg)), + Layer.provide(Bus.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) From c4311dda3125256e1207a8d1f130e8d0d3fde7b2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:27:41 -0400 Subject: [PATCH 13/19] feat(cli): allow effectCmd instance to be a function of args (#25517) --- packages/opencode/src/cli/effect-cmd.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 94ad0232cf..ceb52d07ad 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -37,10 +37,14 @@ interface EffectCmdOpts { * directly under AppRuntime — it can yield any `AppServices` but must not * yield `InstanceRef` (it'd be undefined, causing a defect). * + * Function form: `(args) => boolean` decides per-invocation. Useful for + * commands like `run --attach ` where one flag flips between local + * (needs instance) and remote (doesn't). + * * Use `false` for commands that don't read project state (e.g. `models`, * `serve`, `web`, `account`, `db`, `upgrade`). */ - instance?: boolean + instance?: boolean | ((args: Args) => boolean) /** Defaults to process.cwd(). Override for commands that take a directory positional. */ directory?: (args: Args) => string handler: (args: Args) => Effect.Effect @@ -72,7 +76,8 @@ export const effectCmd = (opts: EffectCmdOpts) => async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args - if (opts.instance === false) { + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { await AppRuntime.runPromise(opts.handler(args)) return } From 2829943ad15da5cd736a7f70f45f54daf488bcdd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:31:20 -0400 Subject: [PATCH 14/19] refactor(cli): convert debug wait, agent list, acp to effectCmd (#25518) --- packages/opencode/src/cli/cmd/acp.ts | 97 ++++++++++---------- packages/opencode/src/cli/cmd/agent.ts | 35 ++++--- packages/opencode/src/cli/cmd/debug/index.ts | 21 +++-- 3 files changed, 76 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 9095984fe3..87671f5a00 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -1,6 +1,6 @@ import * as Log from "@opencode-ai/core/util/log" -import { bootstrap } from "../bootstrap" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" @@ -9,7 +9,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) -export const AcpCommand = cmd({ +export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { @@ -19,52 +19,53 @@ export const AcpCommand = cmd({ default: process.cwd(), }) }, - handler: async (args) => { + handler: Effect.fn("Cli.acp")(function* (args) { process.env.OPENCODE_CLIENT = "acp" - await bootstrap(process.cwd(), async () => { - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) - const sdk = createOpencodeClient({ - baseUrl: `http://${server.hostname}:${server.port}`, - }) - - const input = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - process.stdout.write(chunk, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }, - }) - const output = new ReadableStream({ - start(controller) { - process.stdin.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)) - }) - process.stdin.on("end", () => controller.close()) - process.stdin.on("error", (err) => controller.error(err)) - }, - }) - - const stream = ndJsonStream(input, output) - const agent = await ACP.init({ sdk }) - - new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) - }, stream) - - log.info("setup connection") - process.stdin.resume() - await new Promise((resolve, reject) => { - process.stdin.on("end", resolve) - process.stdin.on("error", reject) - }) + const sdk = createOpencodeClient({ + baseUrl: `http://${server.hostname}:${server.port}`, }) - }, + + const input = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + process.stdout.write(chunk, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }, + }) + const output = new ReadableStream({ + start(controller) { + process.stdin.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + process.stdin.on("end", () => controller.close()) + process.stdin.on("error", (err) => controller.error(err)) + }, + }) + + const stream = ndJsonStream(input, output) + const agent = yield* Effect.promise(() => ACP.init({ sdk })) + + new AgentSideConnection((conn) => { + return agent.create(conn, { sdk }) + }, stream) + + log.info("setup connection") + process.stdin.resume() + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + process.stdin.on("end", () => resolve()) + process.stdin.on("error", reject) + }), + ) + }), }) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 11a6c7f430..4011269495 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -13,6 +13,8 @@ import { Instance } from "../../project/instance" import { WithInstance } from "../../project/with-instance" import { EOL } from "os" import type { Argv } from "yargs" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" type AgentMode = "all" | "primary" | "subagent" @@ -233,28 +235,23 @@ const AgentCreateCommand = cmd({ }, }) -const AgentListCommand = cmd({ +const AgentListCommand = effectCmd({ command: "list", describe: "list all available agents", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - const sortedAgents = agents.sort((a, b) => { - if (a.native !== b.native) { - return a.native ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - for (const agent of sortedAgents) { - process.stdout.write(`${agent.name} (${agent.mode})` + EOL) - process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) - } - }, + handler: Effect.fn("Cli.agent.list")(function* () { + const agents = yield* Agent.Service.use((svc) => svc.list()) + const sortedAgents = agents.sort((a, b) => { + if (a.native !== b.native) { + return a.native ? -1 : 1 + } + return a.name.localeCompare(b.name) }) - }, + + for (const agent of sortedAgents) { + process.stdout.write(`${agent.name} (${agent.mode})` + EOL) + process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) + } + }), }) export const AgentCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 194e66b1f2..2603663fb4 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -1,5 +1,6 @@ import { Global } from "@opencode-ai/core/global" -import { bootstrap } from "../../bootstrap" +import { Duration, Effect } from "effect" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { ConfigCommand } from "./config" import { FileCommand } from "./file" @@ -26,19 +27,19 @@ export const DebugCommand = cmd({ .command(StartupCommand) .command(AgentCommand) .command(PathsCommand) - .command({ - command: "wait", - describe: "wait indefinitely (for debugging)", - async handler() { - await bootstrap(process.cwd(), async () => { - await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24)) - }) - }, - }) + .command(WaitCommand) .demandCommand(), async handler() {}, }) +const WaitCommand = effectCmd({ + command: "wait", + describe: "wait indefinitely (for debugging)", + handler: Effect.fn("Cli.debug.wait")(function* () { + yield* Effect.sleep(Duration.days(1)) + }), +}) + const PathsCommand = cmd({ command: "paths", describe: "show global paths (data, config, cache, state)", From 7409dcc6bdd2329c12b7053d0476bd8802747e7f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:35:20 -0400 Subject: [PATCH 15/19] refactor(cli): convert run command to effectCmd (#25519) --- packages/opencode/src/cli/cmd/cmd.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 37 ++++++++++++++----------- packages/opencode/src/cli/effect-cmd.ts | 6 ++-- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index fe6d62d7b6..05af009b88 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[] } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f73ca67175..72096dba31 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,10 +1,10 @@ import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" -import { bootstrap } from "../bootstrap" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -203,11 +203,17 @@ function normalizePath(input?: string) { return input } -export const RunCommand = cmd({ +export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", - builder: (yargs: Argv) => { - return yargs + // --attach connects to a remote server (no local instance needed); the + // default path runs an in-process server and needs the project instance. + instance: (args) => !args.attach, + // For --dir without --attach, load instance for the resolved target dir. + // The handler also chdirs (preserving the legacy order: chdir → file resolution). + directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), + builder: (yargs: Argv) => + yargs .positional("message", { describe: "message to send", type: "string", @@ -291,9 +297,9 @@ export const RunCommand = cmd({ type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.run")(function* (args) { + yield* Effect.promise(async () => { let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") @@ -661,13 +667,12 @@ export const RunCommand = cmd({ return await execute(sdk) } - await bootstrap(process.cwd(), async () => { - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - return Server.Default().app.fetch(request) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - await execute(sdk) + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + await execute(sdk) }) - }, + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index ceb52d07ad..b0f6de16b7 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" -import { cmd } from "./cmd/cmd" +import { cmd, type WithDoubleDash } from "./cmd/cmd" /** * User-visible command failure. Throw via `fail("...")` from an effectCmd handler @@ -47,7 +47,7 @@ interface EffectCmdOpts { instance?: boolean | ((args: Args) => boolean) /** Defaults to process.cwd(). Override for commands that take a directory positional. */ directory?: (args: Args) => string - handler: (args: Args) => Effect.Effect + handler: (args: WithDoubleDash) => Effect.Effect } /** @@ -75,7 +75,7 @@ export const effectCmd = (opts: EffectCmdOpts) => builder: opts.builder as never, async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. - const args = rawArgs as unknown as Args + const args = rawArgs as unknown as WithDoubleDash const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false if (!useInstance) { await AppRuntime.runPromise(opts.handler(args)) From 61150f63917a893e1b09c9eb1dbced7c7131fb34 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:36:41 +0000 Subject: [PATCH 16/19] chore: generate --- packages/opencode/src/cli/cmd/run.ts | 654 +++++++++++++-------------- 1 file changed, 327 insertions(+), 327 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 72096dba31..75f68e8ea0 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -300,288 +300,310 @@ export const RunCommand = effectCmd({ }), handler: Effect.fn("Cli.run")(function* (args) { yield* Effect.promise(async () => { - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") + let message = [...args.message, ...(args["--"] || [])] + .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .join(" ") - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir - try { - process.chdir(args.dir) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] - - for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) + const directory = (() => { + if (!args.dir) return undefined + if (args.attach) return args.dir + try { + process.chdir(args.dir) + return process.cwd() + } catch { + UI.error("Failed to change directory to " + args.dir) process.exit(1) } + })() - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + if (args.file) { + const list = Array.isArray(args.file) ? args.file : [args.file] - files.push({ - type: "file", - url: pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) - } - } - - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) - - if (message.trim().length === 0 && !args.command) { - UI.error("You must provide a message or a command") - process.exit(1) - } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session - - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id - } - - if (baseID) return baseID - - const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id - } - - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) - } - } - - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } - } - - function emit(type: string, data: Record) { - if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) - return true - } - return false - } - - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { - const toggles = new Map() - - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + for (const filePath of list) { + const resolvedPath = path.resolve(process.cwd(), filePath) + if (!(await Filesystem.exists(resolvedPath))) { + UI.error(`File not found: ${filePath}`) + process.exit(1) } - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue + const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - tool(part) - continue + files.push({ + type: "file", + url: pathToFileURL(resolvedPath).href, + filename: path.basename(resolvedPath), + mime, + }) + } + } + + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + + if (message.trim().length === 0 && !args.command) { + UI.error("You must provide a message or a command") + process.exit(1) + } + + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exit(1) + } + + const rules: Permission.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: OpencodeClient) { + const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + + if (baseID && args.fork) { + const forked = await sdk.session.fork({ sessionID: baseID }) + return forked.data?.id + } + + if (baseID) return baseID + + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id + } + + async function share(sdk: OpencodeClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + } + } + + async function execute(sdk: OpencodeClient) { + function tool(part: ToolPart) { + try { + if (part.tool === ShellID.ToolID) return shell(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) + } catch { + return fallback(part) + } + } + + function emit(type: string, data: Record) { + if (args.format === "json") { + process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + return true + } + return false + } + + const events = await sdk.event.subscribe() + let error: string | undefined + + async function loop() { + const toggles = new Map() + + for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + tool(part) + continue + } + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + UI.error(part.state.error) } - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - UI.error(part.state.error) + + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) + } + + if (part.type === "step-start") { + if (emit("step_start", { part })) continue + } + + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue + } + + if (part.type === "text" && part.time?.end) { + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } + UI.empty() + UI.println(text) + UI.empty() + } + + if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) + } + } + + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue + UI.error(err) } if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" ) { - if (toggles.get(part.id) === true) continue - task(props(part)) - toggles.set(part.id, true) + break } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } - - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue + if (args["dangerously-skip-permissions"]) { + await sdk.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) } - UI.empty() - UI.println(text) - UI.empty() - } - - if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } - } - - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } - - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) } } } - } - // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + // Validate agent if specified + const agent = await (async () => { + if (!args.agent) return undefined + const name = args.agent - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + // When attaching, validate against the running server instead of local Instance state. + if (args.attach) { + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name } - const agent = modes.find((a) => a.name === name) - if (!agent) { + const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -589,8 +611,7 @@ export const RunCommand = effectCmd({ ) return undefined } - - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -598,81 +619,60 @@ export const RunCommand = effectCmd({ ) return undefined } - return name + })() + + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) } + await share(sdk, sessionID) - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], + loop().catch((e) => { + console.error(e) + process.exit(1) }) + + if (args.command) { + await sdk.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + } else { + const model = args.model ? Provider.parseModel(args.model) : undefined + await sdk.session.prompt({ + sessionID, + agent, + model, + variant: args.variant, + parts: [...files, { type: "text", text: message }], + }) + } } - } - if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) - } + if (args.attach) { + const headers = (() => { + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + return { Authorization: auth } + })() + const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + return await execute(sdk) + } - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - return Server.Default().app.fetch(request) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - await execute(sdk) + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + await execute(sdk) }) }), }) From 0956b15c52fdf6741334e5f87109ac95e7870abf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:38:44 -0400 Subject: [PATCH 17/19] refactor(acp): drop async from synchronous ACP.init (#25520) --- packages/opencode/src/acp/agent.ts | 2 +- packages/opencode/src/cli/cmd/acp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8bbc2427fc..d66c1b2583 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -130,7 +130,7 @@ async function sendUsageUpdate( }) } -export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) { +export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { return { create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { return new Agent(connection, fullConfig) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 87671f5a00..251c608843 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -52,7 +52,7 @@ export const AcpCommand = effectCmd({ }) const stream = ndJsonStream(input, output) - const agent = yield* Effect.promise(() => ACP.init({ sdk })) + const agent = ACP.init({ sdk }) new AgentSideConnection((conn) => { return agent.create(conn, { sdk }) From 0ba013f8deb89d049fb6be645be652726741ceff Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 2 May 2026 21:43:48 -0500 Subject: [PATCH 18/19] chore: rm log statement (#25470) --- packages/opencode/src/permission/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 3fedd41d2c..d93670709e 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -144,7 +144,6 @@ interface State { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) return evalRule(permission, pattern, ...rulesets) } From b4cc7d13b65eb382f1a7a3d77aa5e370dc9a219b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 12:44:52 +1000 Subject: [PATCH 19/19] fix(desktop): limit zoom handler to zoom keys (#25516) --- .../src/renderer/webview-zoom.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 9c0a3a3a35..6e13266f45 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -26,13 +26,20 @@ const applyZoom = (next: number) => { window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return - let newZoom = webviewZoom() - - if (event.key === "-") newZoom -= 0.2 - if (event.key === "=" || event.key === "+") newZoom += 0.2 - if (event.key === "0") newZoom = 1 - - applyZoom(clamp(newZoom)) + if (event.key === "-") { + event.preventDefault() + applyZoom(clamp(webviewZoom() - 0.2)) + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + applyZoom(clamp(webviewZoom() + 0.2)) + return + } + if (event.key === "0") { + event.preventDefault() + applyZoom(1) + } }) export { webviewZoom }